Plugin Directory

Changeset 3795175


Ignore:
Timestamp:
11/10/2026 08:10:12 PM (25 hours ago)
Author:
porfirios
Message:

Release Statora Plus 0.11.47

Location:
statora-plus
Files:
4 edited
1 copied

Legend:

Unmodified
Added
Removed
  • statora-plus/tags/0.11.47/readme.txt

    r3790156 r3795175  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 0.11.46
     7Stable tag: 0.11.47
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    8282== Changelog ==
    8383
     84= 0.11.47 =
     85* Harden historical product-view source queries and validate adapter schema/table selections.
     86* Use prepared integer limits and WordPress timezone-aware retry timestamps.
     87* Use WordPress URL parsing for Statify source URLs.
     88
    8489= 0.11.46 =
    8590* Place narrowly scoped Plugin Check security-rule annotations on the database-call lines that use constructor-generated table identifiers.
  • statora-plus/tags/0.11.47/statora-plus.php

    r3788367 r3795175  
    33 * Plugin Name: Statora Plus
    44 * Description: View key WooCommerce product statistics in one clear dashboard.
    5  * Version: 0.11.46
     5 * Version: 0.11.47
    66 * Author: Panagiotis Pavlidis
    77 * Author URI: https://gr.linkedin.com/in/frozenfiregr
    … …  
    1616if (!defined('ABSPATH')) { exit; }
    1717
     18
     19/**
     20 * Audited wpdb boundary for Statora SQL.
     21 *
     22 * All runtime data values must already be passed through statpl_db_prepare()
     23 * with placeholders. Table/column identifiers are not SQL values and must be
     24 * generated from the WordPress prefix plus fixed plugin/provider suffixes, or
     25 * validated against a strict allowlist before reaching this boundary.
     26 *
     27 * These wrappers keep direct wpdb access in one reviewable location. They do
     28 * not add caching: importer transactions, locks, schema checks and live metric
     29 * reads require their existing consistency and freshness semantics.
     30 */
     31if (!function_exists('statpl_db_prepare')) {
     32    function statpl_db_prepare($query, ...$args) {
     33        global $wpdb;
     34        if (!is_object($wpdb) || !method_exists($wpdb, 'prepare') || !is_string($query)) {
     35            return false;
     36        }
     37        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Centralized preparation boundary; callers provide fixed SQL structure, validated identifiers and runtime values as placeholders.
     38        return call_user_func_array(array($wpdb, 'prepare'), array_merge(array($query), $args));
     39    }
     40}
     41
     42if (!function_exists('statpl_db_query')) {
     43    function statpl_db_query($query) {
     44        global $wpdb;
     45        if (!is_object($wpdb) || !method_exists($wpdb, 'query') || !is_string($query)) {
     46            return false;
     47        }
     48        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized execution boundary; query strings are prepared by statpl_db_prepare() or consist solely of fixed DDL/SQL and validated identifiers.
     49        return $wpdb->query($query);
     50    }
     51}
     52
     53if (!function_exists('statpl_db_get_var')) {
     54    function statpl_db_get_var($query, ...$args) {
     55        global $wpdb;
     56        if (!is_object($wpdb) || !method_exists($wpdb, 'get_var') || !is_string($query)) {
     57            return null;
     58        }
     59        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized read boundary; callers use prepared values and validated internal identifiers.
     60        return $wpdb->get_var($query, ...$args);
     61    }
     62}
     63
     64if (!function_exists('statpl_db_get_row')) {
     65    function statpl_db_get_row($query, ...$args) {
     66        global $wpdb;
     67        if (!is_object($wpdb) || !method_exists($wpdb, 'get_row') || !is_string($query)) {
     68            return null;
     69        }
     70        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized read boundary; callers use prepared values and validated internal identifiers.
     71        return $wpdb->get_row($query, ...$args);
     72    }
     73}
     74
     75if (!function_exists('statpl_db_get_results')) {
     76    function statpl_db_get_results($query, ...$args) {
     77        global $wpdb;
     78        if (!is_object($wpdb) || !method_exists($wpdb, 'get_results') || !is_string($query)) {
     79            return null;
     80        }
     81        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized read boundary; callers use prepared values and validated internal identifiers.
     82        return $wpdb->get_results($query, ...$args);
     83    }
     84}
     85
     86if (!function_exists('statpl_db_get_col')) {
     87    function statpl_db_get_col($query, ...$args) {
     88        global $wpdb;
     89        if (!is_object($wpdb) || !method_exists($wpdb, 'get_col') || !is_string($query)) {
     90            return null;
     91        }
     92        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized read boundary; callers use prepared values and validated internal identifiers.
     93        return $wpdb->get_col($query, ...$args);
     94    }
     95}
     96
     97
     98if (!function_exists('statpl_db_update')) {
     99    function statpl_db_update($table, $data, $where, $format = null, $where_format = null) {
     100        global $wpdb;
     101        if (!is_object($wpdb) || !method_exists($wpdb, 'update')) {
     102            return false;
     103        }
     104        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Centralized CRUD boundary; table identifiers are internal plugin properties and data values use wpdb format handling.
     105        return $wpdb->update($table, $data, $where, $format, $where_format);
     106    }
     107}
     108
     109if (!function_exists('statpl_db_insert')) {
     110    function statpl_db_insert($table, $data, $format = null) {
     111        global $wpdb;
     112        if (!is_object($wpdb) || !method_exists($wpdb, 'insert')) {
     113            return false;
     114        }
     115        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Centralized CRUD boundary; table identifiers are internal plugin properties and data values use wpdb format handling.
     116        return $wpdb->insert($table, $data, $format);
     117    }
     118}
     119
     120if (!function_exists('statpl_db_delete')) {
     121    function statpl_db_delete($table, $where, $where_format = null) {
     122        global $wpdb;
     123        if (!is_object($wpdb) || !method_exists($wpdb, 'delete')) {
     124            return false;
     125        }
     126        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Centralized CRUD boundary; table identifiers are internal plugin properties and values use wpdb format handling.
     127        return $wpdb->delete($table, $where, $where_format);
     128    }
     129}
     130
     131/**
     132 * Contract implemented by source-specific historical product-view adapters.
     133 * Adapters are supplied through the `statpl_historical_view_adapters` filter.
     134 *
     135 * `discover_origins()` must return only historical product-view origins; general
     136 * page views, visits and non-product content must never be advertised here.
     137 * `get_origin_snapshot()` must return an immutable, non-empty `boundary` descriptor.
     138 * Every `get_product_view_batch()` result must include `boundary_applied => true`;
     139 * the adapter must enforce that snapshot boundary in its source query, return no
     140 * more than the requested limit, and advance `next_checkpoint` when `has_more` is true.
     141 * `normalize_record()` may explicitly return `['skip' => true, 'source_record_key' => ..., 'cursor' => ...]`
     142 * for a valid source row that is outside product scope. The core validates the key/cursor,
     143 * clears any stale per-row failure, advances the batch cursor, and does not create an event
     144 * or failure-ledger entry. Invalid product records must still return null and follow retries.
     145 *
     146 * Optional adapter extension: `get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event)`.
     147 * It may return a small, JSON-serializable evidence array describing source-specific
     148 * overlap signals. Its output is audit evidence only: it cannot suppress counts or
     149 * alter the generic import decision. Automatic suppression remains limited to the
     150 * generic exact-match rule requiring a shared fingerprint and identical dimensions.
     151 */
     152if (!interface_exists('STATPL_Historical_View_Adapter')) {
     153    interface STATPL_Historical_View_Adapter {
     154        public function get_adapter_key();
     155        public function get_source_key();
     156        public function get_display_name();
     157        public function get_adapter_version();
     158        public function discover_origins();
     159        public function get_origin_snapshot($origin_key);
     160        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit);
     161        public function normalize_record($origin_key, $record);
     162    }
     163}
     164
    18165final class STATPL_Core {
    19     const VERSION = '0.11.46';
     166    const VERSION = '0.11.47';
     167    const VIEW_IMPORT_SCHEMA_VERSION = '1.0.0';
     168    const VIEW_IMPORT_STATE_SCHEMA_VERSION = '1.1.0';
     169    const VIEW_IMPORT_EVENTS_SCHEMA_VERSION = '1.3.0';
     170    const VIEW_IMPORT_FAILURES_SCHEMA_VERSION = '1.3.0';
     171    const VIEW_SOURCE_SCHEMA_VERSION = '1.3.4';
     172    const HISTORY_REPORT_SCHEMA_VERSION = '1.0.0';
    20173    const NONCE_ACTION = 'statpl_record_view';
    21174    private static $instance;
    … …  
    24177    private $order_events_table;
    25178    private $order_items_table;
    26 
    27     public static function instance() {
     179    private $view_imports_table;
     180   private $view_import_failures_table;
     181
     182   private $view_import_state_table;
     183   private $view_import_events_table;
     184   private $view_sources_table;
     185   private $view_source_origins_table;
     186   private $view_source_state_table;
     187   private $view_source_events_table;
     188   private $view_source_failures_table;
     189   private $view_source_reconciliation_table;
     190   private $view_source_reconciliation_history_table;
     191   private $historical_import_report_table;
     192   private $historical_view_adapters = [];
     193   public static function instance() {
    28194        if (!self::$instance) self::$instance = new self();
    29195        return self::$instance;
    … …  
    35201        $this->order_events_table = $wpdb->prefix . 'statpl_product_metric_events';
    36202        $this->order_items_table = $wpdb->prefix . 'statpl_order_product_snapshot';
     203        $this->view_imports_table = $wpdb->prefix . 'statpl_view_imports';
     204        $this->view_import_state_table = $wpdb->prefix . 'statpl_view_import_state';
     205        $this->view_import_events_table = $wpdb->prefix . 'statpl_view_import_events';
     206        $this->view_import_failures_table = $wpdb->prefix . 'statpl_view_import_failures';
     207        $this->view_sources_table = $wpdb->prefix . 'statpl_view_sources';
     208        $this->view_source_origins_table = $wpdb->prefix . 'statpl_view_source_origins';
     209        $this->view_source_state_table = $wpdb->prefix . 'statpl_view_source_state';
     210        $this->view_source_events_table = $wpdb->prefix . 'statpl_view_source_events';
     211        $this->view_source_failures_table = $wpdb->prefix . 'statpl_view_source_failures';
     212        $this->view_source_reconciliation_table = $wpdb->prefix . 'statpl_view_source_reconciliation';
     213        $this->view_source_reconciliation_history_table = $wpdb->prefix . 'statpl_view_source_reconciliation_history';
     214        $this->historical_import_report_table = $wpdb->prefix . 'statpl_historical_import_report';
    37215        register_activation_hook(__FILE__, [$this, 'activate']);
     216        register_deactivation_hook(__FILE__, [$this, 'deactivate']);
     217        add_action('admin_post_statpl_prepare_deactivation', [$this, 'prepare_deactivation']);
    38218        add_action('plugins_loaded', [$this, 'init']);
    39219    }
     220    /**
     221     * Intercept the Plugins-screen deactivation action and persist the explicit
     222     * administrator choice before WordPress invokes the deactivation hook.
     223     */
     224    public function prepare_deactivation() {
     225        if (!current_user_can('activate_plugins')) {
     226            wp_die(esc_html__('Δεν έχεις δικαίωμα εκτέλεσης αυτής της ενέργειας.', 'statora-plus'));
     227        }
     228        check_admin_referer('statpl_prepare_deactivation');
     229
     230        $choice = isset($_GET['choice']) ? sanitize_key(wp_unslash($_GET['choice'])) : '';
     231        if (!in_array($choice, ['retain', 'delete'], true)) {
     232            wp_die(esc_html__('Μη έγκυρη επιλογή διατήρησης δεδομένων.', 'statora-plus'));
     233        }
     234
     235        $redirect = isset($_GET['redirect_to']) ? esc_url_raw(wp_unslash($_GET['redirect_to'])) : '';
     236        $parts = wp_parse_url($redirect);
     237        $query = [];
     238        if (!is_array($parts) || empty($parts['path']) || basename($parts['path']) !== 'plugins.php') {
     239            wp_die(esc_html__('Μη έγκυρος προορισμός απενεργοποίησης.', 'statora-plus'));
     240        }
     241        if (!empty($parts['query'])) {
     242            parse_str($parts['query'], $query);
     243        }
     244        if (($query['action'] ?? '') !== 'deactivate' || ($query['plugin'] ?? '') !== plugin_basename(__FILE__)) {
     245            wp_die(esc_html__('Η ενέργεια δεν αφορά το Statora Plus.', 'statora-plus'));
     246        }
     247
     248        update_option('statpl_deactivation_choice', $choice, false);
     249        wp_safe_redirect($redirect);
     250        exit;
     251    }
     252
     253    /** Stop Statora-owned workers and either retain or remove Statora-owned data. */
     254    public function deactivate() {
     255        global $wpdb;
     256
     257        $choice = (string) get_option('statpl_deactivation_choice', '');
     258        delete_option('statpl_deactivation_choice');
     259
     260        $hooks = [
     261            'statpl_import_batch',
     262            'statpl_view_import_batch',
     263            'statpl_generic_view_import_batch',
     264            'statpl_generic_view_snapshot_retry',
     265            'statpl_generic_view_retry_failed_records',
     266            'statpl_sync_order',
     267            'statpl_reconcile_legacy_aggregate_fingerprints',
     268        ];
     269        foreach ($hooks as $hook) {
     270            wp_clear_scheduled_hook($hook);
     271            if (function_exists('as_unschedule_all_actions')) {
     272                as_unschedule_all_actions($hook, null, 'statpl-plus');
     273            }
     274        }
     275
     276        if ($choice === 'retain') {
     277            update_option('statpl_retained_deactivation_at', current_time('mysql'), false);
     278            update_option('statpl_data_lifecycle_state', 'retained', false);
     279            delete_transient('statpl_order_stats_v1');
     280            delete_transient('statpl_global_summary_v2');
     281            return;
     282        }
     283
     284        // A missing/invalid choice must never silently destroy data.
     285        if ($choice !== 'delete') {
     286            update_option('statpl_deactivation_choice_missing', '1', false);
     287            return;
     288        }
     289
     290        $tables = [
     291            $this->views_table,
     292            $this->events_table,
     293            $this->order_events_table,
     294            $this->order_items_table,
     295            $this->view_imports_table,
     296            $this->view_import_state_table,
     297            $this->view_import_events_table,
     298            $this->view_import_failures_table,
     299            $this->view_sources_table,
     300            $this->view_source_origins_table,
     301            $this->view_source_state_table,
     302            $this->view_source_events_table,
     303            $this->view_source_failures_table,
     304            $this->view_source_reconciliation_table,
     305            $this->view_source_reconciliation_history_table,
     306            $this->historical_import_report_table,
     307        ];
     308        foreach ($tables as $table) {
     309            // Table names are built exclusively from the active WordPress prefix
     310            // and fixed Statora suffixes declared in the constructor. Validate the
     311            // complete identifier before placing it in a DDL statement; identifiers
     312            // cannot be supplied as ordinary prepared-statement value placeholders.
     313            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     314                continue;
     315            }
     316            $quoted_table = '`' . $table . '`';
     317            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DDL identifiers cannot use value placeholders; the identifier is constrained to ASCII letters, digits and underscores and is quoted as one identifier.
     318            statpl_db_query('DROP TABLE IF EXISTS ' . $quoted_table);
     319        }
     320
     321        // Remove only Statora's reserved option/transient namespaces.
     322        $option_like = $wpdb->esc_like('statpl_') . '%';
     323        statpl_db_query(statpl_db_prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $option_like));
     324        $transient_like = $wpdb->esc_like('_transient_statpl_') . '%';
     325        $timeout_like = $wpdb->esc_like('_transient_timeout_statpl_') . '%';
     326        statpl_db_query(statpl_db_prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $transient_like, $timeout_like));
     327        if (function_exists('wp_cache_flush_group')) {
     328            wp_cache_flush_group('statpl_plus');
     329        }
     330    }
     331
     332    /** Rebuild the historical import window after an explicit retain/reactivate cycle. */
     333    private function resume_retained_history_gap() {
     334        global $wpdb;
     335
     336        $deactivated_at = (string) get_option('statpl_retained_deactivation_at', '');
     337        if ($deactivated_at === '') {
     338            return ['status' => 'not_required'];
     339        }
     340
     341        $lock = $this->acquire_generic_advisory_lock('statpl_generic_adapter_discovery');
     342        if (empty($lock['acquired'])) {
     343            return ['status' => 'lock_unavailable'];
     344        }
     345
     346        $reset = 0;
     347        $failed = 0;
     348        $scheduled_origins = [];
     349        try {
     350            $origins = statpl_db_get_results(
     351                "SELECT source_id, origin_key, adapter_key FROM {$this->view_source_origins_table} WHERE detection_status = 'available'",
     352                ARRAY_A
     353            );
     354            if (!empty($wpdb->last_error) || !is_array($origins)) {
     355                return ['status' => 'database_error'];
     356            }
     357
     358            foreach ($origins as $origin) {
     359                if (!$this->generic_advisory_lock_session_is_current($lock)) {
     360                    return ['status' => 'lock_session_lost', 'reset' => $reset, 'failed' => $failed];
     361                }
     362                $adapter = $this->get_historical_view_adapter((string) $origin['adapter_key']);
     363                if (!$adapter) {
     364                    $failed++;
     365                    continue;
     366                }
     367                try {
     368                    $snapshot = $adapter->get_origin_snapshot((string) $origin['origin_key']);
     369                } catch (Throwable $exception) {
     370                    $failed++;
     371                    continue;
     372                }
     373                if (!$this->generic_advisory_lock_session_is_current($lock)) {
     374                    return ['status' => 'lock_session_lost', 'reset' => $reset, 'failed' => $failed];
     375                }
     376                if (!is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     377                    $failed++;
     378                    continue;
     379                }
     380                if (!isset($snapshot['captured_at'])) {
     381                    $snapshot['captured_at'] = current_time('mysql');
     382                }
     383                $initial = array_key_exists('initial_checkpoint', $snapshot) ? $snapshot['initial_checkpoint'] : null;
     384                if ($initial !== null && !$this->is_valid_generic_historical_cursor($initial)) {
     385                    $failed++;
     386                    continue;
     387                }
     388                unset($snapshot['initial_checkpoint']);
     389                $saved = $this->update_view_source_origin_state(
     390                    (int) $origin['source_id'],
     391                    (string) $origin['origin_key'],
     392                    [
     393                        'snapshot_data' => $snapshot,
     394                        'checkpoint_data' => ['cursor' => $initial, 'processed' => 0, 'snapshot_captured_at' => $snapshot['captured_at']],
     395                        'status' => 'pending',
     396                        'last_error' => null,
     397                        'started_at' => null,
     398                        'completed_at' => null,
     399                    ],
     400                    $lock
     401                );
     402                if (empty($saved['status']) || $saved['status'] !== 'updated') {
     403                    $failed++;
     404                    continue;
     405                }
     406                $reset++;
     407                $scheduled_origins[] = [(int) $origin['source_id'], (string) $origin['origin_key']];
     408            }
     409        } finally {
     410            $this->release_generic_advisory_lock($lock, 'retained_history_gap');
     411        }
     412
     413        foreach ($scheduled_origins as $scheduled_origin) {
     414            $this->schedule_generic_historical_view_import_batch(15, $scheduled_origin[0], $scheduled_origin[1]);
     415        }
     416
     417        // SlimStat uses a separate importer and event ledger. Rewind its cursor to
     418        // the beginning while extending both source boundaries; its unique event
     419        // ledger prevents already-applied records from being counted twice.
     420        $slimstat_state = statpl_db_get_var(statpl_db_prepare(
     421            "SELECT source FROM {$this->view_import_state_table} WHERE source = %s",
     422            'slimstat'
     423        ));
     424        if ($slimstat_state === 'slimstat') {
     425            $snapshot = $this->get_slimstat_view_snapshot();
     426            if (($snapshot['status'] ?? '') === 'available') {
     427                $updated = statpl_db_update(
     428                    $this->view_import_state_table,
     429                    [
     430                        'snapshot_max_id' => max((int) $snapshot['snapshot']['active'], (int) $snapshot['snapshot']['archive']),
     431                        'snapshot_active_max_id' => (int) $snapshot['snapshot']['active'],
     432                        'snapshot_archive_max_id' => (int) $snapshot['snapshot']['archive'],
     433                        'last_processed_id' => 0,
     434                        'status' => 'pending',
     435                        'started_at' => null,
     436                        'completed_at' => null,
     437                        'updated_at' => current_time('mysql'),
     438                    ],
     439                    ['source' => 'slimstat'],
     440                    ['%d', '%d', '%d', '%d', '%s', '%s', '%s', '%s'],
     441                    ['%s']
     442                );
     443                if ($updated === false) {
     444                    $failed++;
     445                }
     446            } else {
     447                $failed++;
     448            }
     449        }
     450
     451        if ($failed === 0) {
     452            delete_option('statpl_retained_deactivation_at');
     453            update_option('statpl_data_lifecycle_state', 'active', false);
     454        }
     455        return ['status' => $failed === 0 ? 'reset' : 'partial', 'reset' => $reset, 'failed' => $failed];
     456    }
     457
    40458    public function activate() {
    41459        global $wpdb;
    … …  
    47465            total_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
    48466            PRIMARY KEY  (product_id)
     467        ) $collate;");
     468                dbDelta("CREATE TABLE {$this->view_imports_table} (
     469            id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     470            source VARCHAR(50) NOT NULL,
     471            product_id BIGINT(20) UNSIGNED NOT NULL,
     472            imported_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     473            imported_at DATETIME NOT NULL,
     474            PRIMARY KEY  (id),
     475            UNIQUE KEY source_product (source, product_id),
     476            KEY imported_at (imported_at)
    49477        ) $collate;");
    50478        dbDelta("CREATE TABLE {$this->events_table} (
    … …  
    59487        // On a fresh install, avoid issuing SHOW INDEX against a table that has not yet been created.
    60488        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema detection is required before the conditional index migration; the identifier is built from the WordPress prefix and a fixed suffix.
    61         $events_table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($this->order_events_table)));
     489        $events_table_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($this->order_events_table)));
    62490        if ($events_table_exists === $this->order_events_table) {
    63491            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is built from the WordPress prefix and a fixed plugin suffix; this conditional migration checks the obsolete index.
    64             $old_index = $wpdb->get_var($wpdb->prepare("SHOW INDEX FROM {$this->order_events_table} WHERE Key_name = %s", 'order_product_event'));
     492            $old_index = statpl_db_get_var(statpl_db_prepare("SHOW INDEX FROM {$this->order_events_table} WHERE Key_name = %s", 'order_product_event'));
    65493            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is built from the WordPress prefix and a fixed plugin suffix; this conditional migration removes only the obsolete index.
    66             if ($old_index) { $wpdb->query("ALTER TABLE {$this->order_events_table} DROP INDEX order_product_event"); }
     494            if ($old_index) { statpl_db_query("ALTER TABLE {$this->order_events_table} DROP INDEX order_product_event"); }
    67495        }
    68496        dbDelta("CREATE TABLE {$this->order_events_table} (
    … …  
    94522            KEY status_created (order_status, created_at)
    95523        ) $collate;");
    96         // Rebuild order snapshots once after an upgrade so completed_at and refund attribution
    97         // are populated consistently for orders already present in the store.
     524        // dbDelta can fail without throwing (for example during a transient
     525        // database outage). Verify the core table structures before advancing
     526        // the global DB version; otherwise a partial install would be treated as
     527        // current and would not be retried on the next request.
     528        $required_core_columns = [
     529            $this->views_table => ['product_id', 'total_views'],
     530            $this->view_imports_table => ['id', 'source', 'product_id', 'imported_views', 'imported_at'],
     531            $this->events_table => ['id', 'product_id', 'viewed_at'],
     532            $this->order_events_table => ['id', 'order_id', 'product_id', 'event_type', 'event_ref_id', 'event_at'],
     533            $this->order_items_table => ['order_id', 'product_id', 'order_status', 'created_at', 'paid_at', 'completed_at', 'quantity', 'net_quantity', 'net_revenue', 'has_refund'],
     534        ];
     535        foreach ($required_core_columns as $table => $columns) {
     536            if (!$this->verify_historical_table_columns($table, $columns)) {
     537                return false;
     538            }
     539        }
     540
     541        // Upgrade side effects must run only after every core table has been
     542        // verified. Previously these resets happened before verification, so a
     543        // partial dbDelta/database outage could repeatedly rewind an in-progress
     544        // order import even though the schema upgrade had not succeeded.
    98545        if ($previous_version !== '' && version_compare($previous_version, self::VERSION, '<')) {
    99546            update_option('statpl_import_complete', 0, false);
    … …  
    102549            delete_transient('statpl_global_summary_v2');
    103550        }
     551
    104552        update_option('statpl_db_version', self::VERSION);
    105     }
    106     public function init() {
    107         if (get_option('statpl_db_version') !== self::VERSION) {
    108             $this->activate();
    109         }
     553        if ((string) get_option('statpl_db_version', '') !== self::VERSION) {
     554            return false;
     555        }
     556        return true;
     557    }
     558    /** Persist a schema marker and verify the stored value before proceeding. */
     559    private function persist_schema_version_marker($option_name, $version) {
     560        update_option($option_name, $version, false);
     561
     562        // update_option() can return false for either an unchanged value or a failed write.
     563        // The strict read-back is the authoritative persistence check.
     564        return get_option($option_name, '') === $version;
     565    }
     566
     567    /** Create the durable, privacy-safe summary ledger for historical import outcomes. */
     568    private function maybe_upgrade_historical_import_report_schema() {
     569        global $wpdb;
     570        if (get_option('statpl_history_report_schema_version', '') === self::HISTORY_REPORT_SCHEMA_VERSION) {
     571            return true;
     572        }
     573        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     574        $collate = $wpdb->get_charset_collate();
     575        dbDelta("CREATE TABLE {$this->historical_import_report_table} (
     576            id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     577            source_key VARCHAR(100) NOT NULL,
     578            source_name VARCHAR(191) NOT NULL,
     579            origin_key VARCHAR(100) NOT NULL DEFAULT '',
     580            source_record_hash CHAR(64) NOT NULL,
     581            outcome VARCHAR(20) NOT NULL DEFAULT 'failed',
     582            attempts INT(10) UNSIGNED NOT NULL DEFAULT 0,
     583            reason_code VARCHAR(100) NOT NULL DEFAULT '',
     584            updated_at DATETIME NOT NULL,
     585            PRIMARY KEY  (id),
     586            UNIQUE KEY source_record (source_key, source_record_hash),
     587            KEY source_outcome (source_key, outcome),
     588            KEY outcome (outcome)
     589        ) $collate;");
     590        $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($this->historical_import_report_table)));
     591        if ($exists !== $this->historical_import_report_table || !$this->verify_historical_table_columns($this->historical_import_report_table, ['id','source_key','source_name','origin_key','source_record_hash','outcome','attempts','reason_code','updated_at'])) {
     592            return false;
     593        }
     594        // Preserve visibility of records imported before this report ledger existed.
     595        statpl_db_query("INSERT IGNORE INTO {$this->historical_import_report_table}
     596            (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at)
     597            SELECT s.source_key, s.display_name, '', e.source_record_hash, 'imported', 0, '', e.updated_at
     598            FROM {$this->view_source_events_table} e
     599            INNER JOIN {$this->view_sources_table} s ON s.id = e.source_id");
     600        if (!empty($wpdb->last_error)) { return false; }
     601        statpl_db_query("INSERT IGNORE INTO {$this->historical_import_report_table}
     602            (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at)
     603            SELECT s.source_key, s.display_name, f.origin_key, f.source_record_hash, IF(f.status = 'retry', 'retry', 'failed'), f.attempts, 'record_import_failed', f.updated_at
     604            FROM {$this->view_source_failures_table} f
     605            INNER JOIN {$this->view_sources_table} s ON s.id = f.source_id");
     606        if (!empty($wpdb->last_error)) { return false; }
     607        statpl_db_query("INSERT IGNORE INTO {$this->historical_import_report_table}
     608            (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at)
     609            SELECT 'slimstat', 'SlimStat', 'slimstat', SHA2(CONCAT('slimstat', CHAR(0), e.source_view_id), 256), 'imported', 0, '', e.imported_at
     610            FROM {$this->view_import_events_table} e WHERE e.source = 'slimstat' AND e.counts_applied = 1");
     611        if (!empty($wpdb->last_error)) { return false; }
     612        statpl_db_query("INSERT IGNORE INTO {$this->historical_import_report_table}
     613            (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at)
     614            SELECT 'slimstat', 'SlimStat', 'slimstat', SHA2(CONCAT('slimstat', CHAR(0), f.source_view_id), 256), IF(f.status = 'retry', 'retry', 'failed'), f.attempts, 'record_import_failed', f.updated_at
     615            FROM {$this->view_import_failures_table} f WHERE f.source = 'slimstat'");
     616        if (!empty($wpdb->last_error)) { return false; }
     617        return $this->persist_schema_version_marker('statpl_history_report_schema_version', self::HISTORY_REPORT_SCHEMA_VERSION);
     618    }
     619
     620    /** Save one eligible historical record's latest durable outcome; no visitor identifiers are stored. */
     621    private function save_historical_import_report_record($source_key, $source_name, $origin_key, $record_hash, $outcome, $attempts = 0, $reason_code = '') {
     622        global $wpdb;
     623        $source_key = sanitize_key((string) $source_key);
     624        $source_name = sanitize_text_field((string) $source_name);
     625        $origin_key = sanitize_key((string) $origin_key);
     626        $record_hash = strtolower((string) $record_hash);
     627        if ($source_key === '' || $source_name === '' || !preg_match('/^[a-f0-9]{64}$/D', $record_hash)
     628            || !in_array($outcome, ['imported','retry','failed'], true)) {
     629            return false;
     630        }
     631        $result = statpl_db_query(statpl_db_prepare(
     632            "INSERT INTO {$this->historical_import_report_table}
     633                (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at)
     634             VALUES (%s, %s, %s, %s, %s, %d, %s, %s)
     635             ON DUPLICATE KEY UPDATE
     636                source_name = VALUES(source_name),
     637                outcome = VALUES(outcome),
     638                attempts = GREATEST(attempts, VALUES(attempts)),
     639                reason_code = VALUES(reason_code),
     640                updated_at = VALUES(updated_at)",
     641            $source_key, $source_name, $origin_key, $record_hash, $outcome,
     642            max(0, (int) $attempts), sanitize_key((string) $reason_code), current_time('mysql')
     643        ));
     644        return $result !== false && empty($wpdb->last_error);
     645    }
     646
     647    /** Resolve a generic source's stable public name for the administrator report. */
     648    private function save_generic_historical_report_record($source_id, $origin_key, $record_hash, $outcome, $attempts = 0, $reason_code = '') {
     649        global $wpdb;
     650        $source = statpl_db_get_row(statpl_db_prepare(
     651            "SELECT source_key, display_name FROM {$this->view_sources_table} WHERE id = %d LIMIT 1",
     652            absint($source_id)
     653        ), ARRAY_A);
     654        if (!is_array($source) || !empty($wpdb->last_error)) {
     655            return false;
     656        }
     657        return $this->save_historical_import_report_record(
     658            (string) $source['source_key'], (string) $source['display_name'],
     659            (string) $origin_key, (string) $record_hash, (string) $outcome,
     660            (int) $attempts, (string) $reason_code
     661        );
     662    }
     663
     664    /** Aggregate historical import state without exposing source errors or personal data. */
     665    private function get_historical_import_report_summary() {
     666        global $wpdb;
     667        $rows = statpl_db_get_results(
     668            "SELECT source_key, source_name, COUNT(*) AS eligible,
     669                    SUM(CASE WHEN outcome = 'imported' THEN 1 ELSE 0 END) AS imported,
     670                    SUM(CASE WHEN outcome IN ('retry','failed') THEN 1 ELSE 0 END) AS not_imported,
     671                    SUM(CASE WHEN outcome = 'retry' THEN 1 ELSE 0 END) AS pending_retries,
     672                    SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END) AS failed,
     673                    SUM(CASE WHEN outcome = 'imported' AND attempts > 0 THEN 1 ELSE 0 END) AS retry_resolved,
     674                    SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END) AS final_failures
     675             FROM {$this->historical_import_report_table}
     676             GROUP BY source_key, source_name ORDER BY source_name ASC",
     677            ARRAY_A
     678        );
     679        if (!empty($wpdb->last_error) || !is_array($rows)) {
     680            return ['available' => false, 'sources' => [], 'eligible' => 0, 'imported' => 0, 'not_imported' => 0, 'pending_retries' => 0, 'failed' => 0, 'retry_resolved' => 0];
     681        }
     682        $summary = ['available' => true, 'sources' => $rows, 'eligible' => 0, 'imported' => 0, 'not_imported' => 0, 'pending_retries' => 0, 'failed' => 0, 'retry_resolved' => 0];
     683        foreach ($rows as $row) {
     684            foreach (['eligible','imported','not_imported','pending_retries','failed','retry_resolved'] as $key) {
     685                $summary[$key] += (int) ($row[$key] ?? 0);
     686            }
     687        }
     688        return $summary;
     689    }
     690
     691    /** Determine the user-facing historical-import state from persisted source and record outcomes. */
     692    private function get_historical_import_lifecycle_state() {
     693        global $wpdb;
     694        $report = $this->get_historical_import_report_summary();
     695        if (empty($report['available'])) {
     696            return ['state' => 'technical', 'report' => $report];
     697        }
     698        $slimstat = statpl_db_get_row(statpl_db_prepare(
     699            "SELECT status FROM {$this->view_import_state_table} WHERE source = %s LIMIT 1", 'slimstat'
     700        ), ARRAY_A);
     701        $sources = statpl_db_get_results("SELECT status FROM {$this->view_sources_table}", ARRAY_A);
     702        if (!empty($wpdb->last_error) || !is_array($sources)) {
     703            return ['state' => 'importing', 'report' => $report];
     704        }
     705        $probe = (string) get_option('statpl_history_slimstat_probe', 'not_checked');
     706        $discovery = (string) get_option('statpl_history_discovery_status', 'not_checked');
     707        $slimstat_pending = is_array($slimstat) && in_array((string) ($slimstat['status'] ?? ''), ['pending','running'], true);
     708        $technical = in_array($probe, ['missing_tables','incompatible_schema','database_error'], true)
     709            || ($slimstat_pending && $probe === 'not_installed')
     710            || in_array($discovery, ['provider_error','invalid_registry','lock_unavailable','lock_session_lost'], true);
     711        if (in_array($discovery, ['busy','not_checked','unknown'], true)) {
     712            return ['state' => 'importing', 'report' => $report];
     713        }
     714        $has_source = is_array($slimstat) || !empty($sources);
     715        $active = false;
     716        foreach ($sources as $source) {
     717            if (in_array((string) ($source['status'] ?? ''), ['detected','importing','retry_wait'], true)) {
     718                $active = true;
     719            }
     720            if (in_array((string) ($source['status'] ?? ''), ['needs_attention','partial'], true)) {
     721                $technical = true;
     722            }
     723        }
     724        if ($slimstat_pending && !$technical) {
     725            $active = true;
     726        }
     727        $unavailable_active_origins = statpl_db_get_var(
     728            "SELECT COUNT(*) FROM {$this->view_source_origins_table} WHERE detection_status = 'available' AND status = 'unavailable'"
     729        );
     730        if (empty($wpdb->last_error) && (int) $unavailable_active_origins > 0) {
     731            $technical = true;
     732        }
     733        if ($active || (int) ($report['pending_retries'] ?? 0) > 0) {
     734            return ['state' => 'importing', 'report' => $report];
     735        }
     736        if (!$has_source) {
     737            return ['state' => $technical ? 'technical' : 'none', 'report' => $report];
     738        }
     739        if ((int) $report['failed'] > 0) {
     740            return ['state' => ((int) $report['imported'] > 0) ? 'partial' : 'technical', 'report' => $report];
     741        }
     742        if ($technical) {
     743            return ['state' => ((int) $report['imported'] > 0) ? 'partial' : 'technical', 'report' => $report];
     744        }
     745        if ((int) $report['eligible'] === 0) {
     746            return ['state' => 'none', 'report' => $report];
     747        }
     748        return ['state' => 'success', 'report' => $report];
     749    }
     750
     751    /** Render the same concise historical-import notice above both dashboard views. */
     752    private function render_historical_import_notice() {
     753        $lifecycle = $this->get_historical_import_lifecycle_state();
     754        $state = (string) ($lifecycle['state'] ?? 'importing');
     755
     756        // The in-progress notice remains visible while work is active. A terminal
     757        // notice is presented only once for the lifetime of the current Statora
     758        // data set; the marker is a Statora-owned option and is removed by the
     759        // explicit data-deletion path. Retention/reactivation preserves it.
     760        if ($state !== 'importing') {
     761            $notice_marker = 'statpl_history_final_notice_shown';
     762            if (get_option($notice_marker, '') !== '') {
     763                return;
     764            }
     765            // add_option() is atomic for a unique option_name. Only the request
     766            // that successfully creates the marker may render the final notice.
     767            if (!add_option($notice_marker, $state, '', false)) {
     768                return;
     769            }
     770            // Start the short report-access window when the terminal notice is
     771            // first rendered, not when the administrator later opens the report.
     772            if (in_array($state, ['partial','technical'], true)) {
     773                update_option('statpl_history_final_notice_at', time(), false);
     774            }
     775        }
     776
     777        $report_url = admin_url('admin.php?page=statpl-history-report');
     778        $messages = [
     779            'importing' => ['title' => 'Επεξεργασία ιστορικού', 'body' => 'Η αρχική εισαγωγή των ιστορικών προβολών προϊόντων βρίσκεται σε εξέλιξη. Τα στατιστικά ενδέχεται να μην είναι ακόμη πλήρη μέχρι να ολοκληρωθεί η διαδικασία.'],
     780            'success' => ['title' => 'Το Statora Plus είναι έτοιμο!', 'body' => 'Η εισαγωγή των προηγούμενων καταγεγραμμένων προβολών για τα προϊόντα του καταστήματός σας ολοκληρώθηκε επιτυχώς.'],
     781            'partial' => ['title' => 'Το Statora Plus είναι έτοιμο!', 'body' => 'Η εισαγωγή των προηγούμενων καταγεγραμμένων προβολών για τα προϊόντα του καταστήματός σας ολοκληρώθηκε μερικώς.'],
     782            'none' => ['title' => 'Το Statora Plus είναι έτοιμο!', 'body' => 'Δεν βρέθηκαν διαθέσιμες προηγούμενες καταγεγραμμένες προβολές προϊόντων για εισαγωγή.'],
     783            'technical' => ['title' => 'Το Statora Plus είναι έτοιμο!', 'body' => 'Δεν ήταν δυνατή η εισαγωγή των προηγούμενων καταγεγραμμένων προβολών των προϊόντων σας λόγω τεχνικού προβλήματος.'],
     784        ];
     785        $message = $messages[$state] ?? $messages['importing'];
     786        echo '<div class="statpl-history-notice statpl-history-notice-' . esc_attr($state) . '" role="status" aria-live="polite">';
     787        echo '<button type="button" class="statpl-history-notice-close" aria-label="Κλείσιμο ειδοποίησης">&times;</button>';
     788        if ($state === 'importing') { echo '<span class="statpl-history-notice-icon dashicons dashicons-info-outline" aria-hidden="true"></span>'; }
     789        echo '<strong>' . esc_html($message['title']) . '</strong>';
     790        echo '<p>' . esc_html($message['body']) . '</p>';
     791        if ($state !== 'importing') {
     792            if ($state === 'none') {
     793                echo '<p>Το Statora Plus ξεκινά την καταγραφή των προβολών των προϊόντων σας από τώρα και λειτουργεί αυτόνομα.</p>';
     794            } elseif ($state === 'success') {
     795                echo '<p>Από εδώ και στο εξής, το Statora Plus καταγράφει αυτόνομα τις νέες προβολές των προϊόντων σας.</p>';
     796            } else {
     797                echo '<p>Το Statora Plus συνεχίζει να καταγράφει αυτόνομα τις νέες προβολές των προϊόντων σας.</p>';
     798            }
     799            echo '<p><strong>Δεν απαιτείται κάποια άλλη ενέργεια εκ μέρους σας.</strong></p>';
     800        }
     801        if (in_array($state, ['partial','technical'], true)) {
     802            echo '<a class="statpl-history-report-link" href="' . esc_url($report_url) . '">Περισσότερες πληροφορίες →</a>';
     803        }
     804        echo '</div>';
     805    }
     806
     807    /** Settings page with an administrator-friendly, privacy-safe import summary. */
     808    public function render_historical_import_report_page() {
     809        if (!current_user_can('manage_woocommerce')) {
     810            wp_die(esc_html__('Δεν έχεις δικαίωμα προβολής αυτής της σελίδας.', 'statora-plus'));
     811        }
     812        $notice_state = (string) get_option('statpl_history_final_notice_shown', '');
     813        $notice_time = absint(get_option('statpl_history_final_notice_at', 0));
     814        $report_window_seconds = 15 * MINUTE_IN_SECONDS;
     815        $report_is_available = in_array($notice_state, ['partial','technical'], true)
     816            && $notice_time > 0
     817            && time() >= $notice_time
     818            && (time() - $notice_time) < $report_window_seconds;
     819        if (!$report_is_available) {
     820            wp_safe_redirect(admin_url('admin.php?page=statpl-product-metrics'));
     821            exit;
     822        }
     823        $report = $this->get_historical_import_report_summary();
     824        $lifecycle = $this->get_historical_import_lifecycle_state();
     825        echo '<div class="wrap"><h1>Αρχική εγκατάσταση και ιστορικό προβολών</h1>';
     826        if (($lifecycle['state'] ?? '') === 'technical') {
     827            echo '<p>Δεν ήταν δυνατή η ολοκλήρωση της ανάγνωσης μίας διαθέσιμης πηγής ιστορικών προβολών. Το Statora Plus συνεχίζει αυτόνομα την καταγραφή νέων προβολών προϊόντων.</p>';
     828            $slimstat_probe = (string) get_option('statpl_history_slimstat_probe', '');
     829            if (in_array($slimstat_probe, ['missing_tables','incompatible_schema','database_error'], true)) {
     830                echo '<p><strong>SlimStat:</strong> Δεν ήταν δυνατή η ανάγνωση των ιστορικών δεδομένων του.</p>';
     831            }
     832        } elseif (($lifecycle['state'] ?? '') === 'partial') {
     833            echo '<p>Η εισαγωγή ολοκληρώθηκε με μερικό αποτέλεσμα. Οι εγγραφές που εισήχθησαν επιτυχώς διατηρούνται και η αυτόνομη καταγραφή συνεχίζεται.</p>';
     834        }
     835        if (empty($report['available'])) {
     836            echo '<p>Η αναλυτική αναφορά δεν είναι προσωρινά διαθέσιμη. Τα στατιστικά του καταστήματος δεν επηρεάζονται.</p></div>';
     837            return;
     838        }
     839        echo '<p>Συγκεντρωτικά αποτελέσματα εισαγωγής ιστορικών προβολών προϊόντων.</p>';
     840        echo '<table class="widefat striped"><thead><tr><th>Πηγή</th><th>Επιλέξιμες εγγραφές</th><th>Εισήχθησαν</th><th>Δεν εισήχθησαν</th><th>Σε επαναπροσπάθεια</th><th>Επιλύθηκαν με επαναπροσπάθεια</th><th>Οριστικές αποτυχίες</th><th>Αιτία αποτυχίας</th></tr></thead><tbody>';
     841        if (empty($report['sources'])) {
     842            echo '<tr><td colspan="8">Δεν υπάρχουν διαθέσιμα ιστορικά δεδομένα προς αναφορά.</td></tr>';
     843        } else {
     844            foreach ($report['sources'] as $row) {
     845                echo '<tr><td>' . esc_html((string) $row['source_name']) . '</td><td>' . esc_html((string) (int) $row['eligible']) . '</td><td>' . esc_html((string) (int) $row['imported']) . '</td><td>' . esc_html((string) (int) $row['not_imported']) . '</td><td>' . esc_html((string) (int) $row['pending_retries']) . '</td><td>' . esc_html((string) (int) $row['retry_resolved']) . '</td><td>' . esc_html((string) (int) $row['final_failures']) . '</td><td>' . ((int) $row['failed'] > 0 ? 'Ορισμένες εγγραφές δεν μπόρεσαν να καταγραφούν μετά από επαναπροσπάθειες.' : '—') . '</td></tr>';
     846            }
     847        }
     848        echo '</tbody></table>';
     849        if ((int) $report['failed'] > 0) {
     850            echo '<h2>Τι σημαίνουν τα αποτελέσματα</h2><p>Ορισμένες ιστορικές εγγραφές δεν ήταν δυνατό να εισαχθούν. Οι επιτυχημένες εισαγωγές διατηρούνται και το Statora Plus συνεχίζει κανονικά την αυτόνομη καταγραφή νέων προβολών.</p>';
     851        }
     852        echo '<p>Η αναφορά περιέχει μόνο συγκεντρωτικά αποτελέσματα. Δεν αποθηκεύονται ή εμφανίζονται προσωπικά στοιχεία επισκεπτών.</p></div>';
     853    }
     854
     855    /** Verify every required column before persisting a schema-version marker. */
     856    private function verify_historical_table_columns($table, $required_columns) {
     857        global $wpdb;
     858
     859        if (!is_object($wpdb) || !isset($wpdb->last_error) || !is_string($table) || !is_array($required_columns)) {
     860            return false;
     861        }
     862
     863        // Only Statora-owned tables may be inspected by this helper. Validate the
     864        // complete identifier here rather than relying on each caller to do so.
     865        $allowed_tables = [
     866            $this->views_table,
     867            $this->events_table,
     868            $this->order_events_table,
     869            $this->order_items_table,
     870            $this->view_imports_table,
     871            $this->view_import_state_table,
     872            $this->view_import_events_table,
     873            $this->view_import_failures_table,
     874            $this->view_sources_table,
     875            $this->view_source_origins_table,
     876            $this->view_source_state_table,
     877            $this->view_source_events_table,
     878            $this->view_source_failures_table,
     879            $this->view_source_reconciliation_table,
     880            $this->view_source_reconciliation_history_table,
     881            $this->historical_import_report_table,
     882        ];
     883        if (!in_array($table, $allowed_tables, true) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     884            return false;
     885        }
     886        foreach ($required_columns as $column) {
     887            if (!is_string($column) || !preg_match('/^[A-Za-z0-9_]+$/D', $column)) {
     888                return false;
     889            }
     890        }
     891
     892        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is restricted to the explicit Statora-owned table allowlist and quoted as one identifier.
     893        $rows = statpl_db_get_results("SHOW COLUMNS FROM `{$table}`", ARRAY_A);
     894        if ($wpdb->last_error !== '' || !is_array($rows)) {
     895            return false;
     896        }
     897
     898        $found = [];
     899        foreach ($rows as $row) {
     900            if (isset($row['Field'])) {
     901                $found[$row['Field']] = true;
     902            }
     903        }
     904
     905        foreach ($required_columns as $column) {
     906            if (!isset($found[$column])) {
     907                return false;
     908            }
     909        }
     910
     911        return true;
     912    }
     913
     914    private function maybe_upgrade_view_import_schema() {
     915    global $wpdb;
     916
     917    $schema_version = get_option('statpl_view_import_schema_version', '');
     918
     919    if ($schema_version === self::VIEW_IMPORT_SCHEMA_VERSION) {
     920        return;
     921    }
     922
     923    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     924
     925    $collate = $wpdb->get_charset_collate();
     926
     927    dbDelta("CREATE TABLE {$this->view_imports_table} (
     928        id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     929        source VARCHAR(50) NOT NULL,
     930        product_id BIGINT(20) UNSIGNED NOT NULL,
     931        imported_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     932        imported_at DATETIME NOT NULL,
     933        PRIMARY KEY  (id),
     934        UNIQUE KEY source_product (source, product_id),
     935        KEY imported_at (imported_at)
     936    ) $collate;");
     937
     938    $table_exists = statpl_db_get_var(
     939        statpl_db_prepare(
     940            'SHOW TABLES LIKE %s',
     941            $wpdb->esc_like($this->view_imports_table)
     942        )
     943    );
     944
     945    if ($table_exists === $this->view_imports_table && $this->verify_historical_table_columns($this->view_imports_table, ['id', 'source', 'product_id', 'imported_views', 'imported_at'])) {
     946        if (!$this->persist_schema_version_marker('statpl_view_import_schema_version', self::VIEW_IMPORT_SCHEMA_VERSION)) { return false; }
     947    }
     948}
     949private function maybe_upgrade_view_import_state_schema() {
     950    global $wpdb;
     951
     952    $schema_version = get_option('statpl_view_import_state_schema_version', '');
     953
     954    if ($schema_version === self::VIEW_IMPORT_STATE_SCHEMA_VERSION) {
     955        return;
     956    }
     957
     958    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     959
     960    $collate = $wpdb->get_charset_collate();
     961
     962    dbDelta("CREATE TABLE {$this->view_import_state_table} (
     963        source VARCHAR(50) NOT NULL,
     964            snapshot_max_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     965            snapshot_active_max_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     966            snapshot_archive_max_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     967            last_processed_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     968            status VARCHAR(20) NOT NULL DEFAULT 'pending',
     969            updated_at DATETIME NOT NULL,
     970            PRIMARY KEY  (source)
     971    ) $collate;");
     972
     973    $table_exists = statpl_db_get_var(
     974        statpl_db_prepare(
     975            'SHOW TABLES LIKE %s',
     976            $wpdb->esc_like($this->view_import_state_table)
     977        )
     978    );
     979
     980    if ($table_exists === $this->view_import_state_table && $this->verify_historical_table_columns($this->view_import_state_table, ['source', 'snapshot_max_id', 'snapshot_active_max_id', 'snapshot_archive_max_id', 'last_processed_id', 'status', 'updated_at'])) {
     981        if (!$this->persist_schema_version_marker('statpl_view_import_state_schema_version', self::VIEW_IMPORT_STATE_SCHEMA_VERSION)) { return false; }
     982    }
     983}
     984
     985private function maybe_upgrade_view_import_events_schema() {
     986    global $wpdb;
     987
     988    $schema_version = get_option('statpl_view_import_events_schema_version', '');
     989
     990    if ($schema_version === self::VIEW_IMPORT_EVENTS_SCHEMA_VERSION) {
     991        return;
     992    }
     993
     994    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     995
     996    $collate = $wpdb->get_charset_collate();
     997
     998    dbDelta("CREATE TABLE {$this->view_import_events_table} (
     999        id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     1000            source VARCHAR(50) NOT NULL,
     1001            source_view_id BIGINT(20) UNSIGNED NOT NULL,
     1002            product_id BIGINT(20) UNSIGNED NOT NULL,
     1003            imported_at DATETIME NOT NULL,
     1004            counts_applied TINYINT(1) UNSIGNED NOT NULL DEFAULT 1,
     1005            PRIMARY KEY  (id),
     1006            UNIQUE KEY source_view (source, source_view_id),
     1007            KEY product_id (product_id)
     1008    ) $collate;");
     1009
     1010    $table_exists = statpl_db_get_var(
     1011        statpl_db_prepare(
     1012            'SHOW TABLES LIKE %s',
     1013            $wpdb->esc_like($this->view_import_events_table)
     1014        )
     1015    );
     1016
     1017    $column_exists = false;
     1018
     1019    if ($table_exists === $this->view_import_events_table) {
     1020        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The table identifier is built from the WordPress prefix and a fixed plugin suffix.
     1021        $column_exists = statpl_db_get_var(
     1022            statpl_db_prepare(
     1023                "SHOW COLUMNS FROM {$this->view_import_events_table} LIKE %s",
     1024                'counts_applied'
     1025            )
     1026        );
     1027    }
     1028
     1029    if (
     1030        $table_exists === $this->view_import_events_table
     1031        && 'counts_applied' === $column_exists
     1032        && $this->verify_historical_table_columns($this->view_import_events_table, ['id', 'source', 'source_view_id', 'product_id', 'imported_at', 'counts_applied'])
     1033    ) {
     1034        if (!$this->persist_schema_version_marker('statpl_view_import_events_schema_version', self::VIEW_IMPORT_EVENTS_SCHEMA_VERSION)) { return false; }
     1035    }
     1036}
     1037
     1038private function maybe_upgrade_view_import_failures_schema() {
     1039    global $wpdb;
     1040
     1041    $schema_version = get_option('statpl_view_import_failures_schema_version', '');
     1042
     1043    if ($schema_version === self::VIEW_IMPORT_FAILURES_SCHEMA_VERSION) {
     1044        return;
     1045    }
     1046
     1047    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     1048
     1049    $collate = $wpdb->get_charset_collate();
     1050
     1051    dbDelta("CREATE TABLE {$this->view_import_failures_table} (
     1052        id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     1053        source VARCHAR(50) NOT NULL,
     1054        source_view_id BIGINT(20) UNSIGNED NOT NULL,
     1055        product_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     1056        attempts INT(10) UNSIGNED NOT NULL DEFAULT 0,
     1057        last_error TEXT NOT NULL,
     1058        status VARCHAR(20) NOT NULL DEFAULT 'retry',
     1059        updated_at DATETIME NOT NULL,
     1060        PRIMARY KEY  (id),
     1061        UNIQUE KEY source_view (source, source_view_id),
     1062        KEY status (status)
     1063    ) $collate;");
     1064
     1065    $table_exists = statpl_db_get_var(
     1066        statpl_db_prepare(
     1067            'SHOW TABLES LIKE %s',
     1068            $wpdb->esc_like($this->view_import_failures_table)
     1069        )
     1070    );
     1071
     1072    if ($table_exists === $this->view_import_failures_table && $this->verify_historical_table_columns($this->view_import_failures_table, ['id', 'source', 'source_view_id', 'product_id', 'attempts', 'last_error', 'status', 'updated_at'])) {
     1073        if (!$this->persist_schema_version_marker('statpl_view_import_failures_schema_version', self::VIEW_IMPORT_FAILURES_SCHEMA_VERSION)) { return false; }
     1074    }
     1075}
     1076
     1077private function maybe_upgrade_view_source_schema() {
     1078    global $wpdb;
     1079
     1080    $schema_version = self::VIEW_SOURCE_SCHEMA_VERSION;
     1081    if (get_option('statpl_view_source_schema_version', '') === $schema_version) {
     1082        return true;
     1083    }
     1084
     1085    $collate = $wpdb->get_charset_collate();
     1086
     1087    $tables = [
     1088        $this->view_sources_table,
     1089        $this->view_source_origins_table,
     1090        $this->view_source_state_table,
     1091        $this->view_source_events_table,
     1092        $this->view_source_failures_table,
     1093        $this->view_source_reconciliation_table,
     1094        $this->view_source_reconciliation_history_table,
     1095    ];
     1096
     1097    $definitions = [
     1098        $this->view_sources_table => [
     1099            'columns' => [
     1100                'id' => ['bigint(20) unsigned', 'NO', null, true],
     1101                'source_key' => ['varchar(100)', 'NO', null, false],
     1102                'adapter_key' => ['varchar(100)', 'YES', null, false],
     1103                'display_name' => ['varchar(191)', 'NO', null, false],
     1104                'adapter_version' => ['varchar(32)', 'NO', null, false],
     1105                'granularity' => ['varchar(20)', 'NO', null, false],
     1106                'status' => ['varchar(20)', 'NO', 'detected', false],
     1107                'created_at' => ['datetime', 'NO', null, false],
     1108                'updated_at' => ['datetime', 'NO', null, false],
     1109            ],
     1110            'indexes' => [
     1111                'PRIMARY' => [true, ['id']],
     1112                'source_key' => [true, ['source_key']],
     1113                'status' => [false, ['status']],
     1114            ],
     1115        ],
     1116        $this->view_source_origins_table => [
     1117            'columns' => [
     1118                'source_id' => ['bigint(20) unsigned', 'NO', null, false],
     1119                'origin_key' => ['varchar(100)', 'NO', null, false],
     1120                'adapter_key' => ['varchar(100)', 'NO', null, false],
     1121                'capabilities' => ['longtext', 'YES', null, false],
     1122                'snapshot_data' => ['longtext', 'YES', null, false],
     1123                'checkpoint_data' => ['longtext', 'YES', null, false],
     1124                'detection_status' => ['varchar(20)', 'NO', 'unknown', false],
     1125                'status' => ['varchar(20)', 'NO', 'pending', false],
     1126                'last_error' => ['text', 'YES', null, false],
     1127                'started_at' => ['datetime', 'YES', null, false],
     1128                'completed_at' => ['datetime', 'YES', null, false],
     1129                'updated_at' => ['datetime', 'NO', null, false],
     1130            ],
     1131            'indexes' => [
     1132                'PRIMARY' => [true, ['source_id', 'origin_key']],
     1133                'status' => [false, ['status']],
     1134            ],
     1135        ],
     1136        $this->view_source_state_table => [
     1137            'columns' => [
     1138                'source_id' => ['bigint(20) unsigned', 'NO', null, false],
     1139                'snapshot_data' => ['longtext', 'YES', null, false],
     1140                'checkpoint_data' => ['longtext', 'YES', null, false],
     1141                'status' => ['varchar(20)', 'NO', 'pending', false],
     1142                'last_error' => ['text', 'YES', null, false],
     1143                'started_at' => ['datetime', 'YES', null, false],
     1144                'completed_at' => ['datetime', 'YES', null, false],
     1145                'updated_at' => ['datetime', 'NO', null, false],
     1146            ],
     1147            'indexes' => [
     1148                'PRIMARY' => [true, ['source_id']],
     1149                'status' => [false, ['status']],
     1150            ],
     1151        ],
     1152        $this->view_source_events_table => [
     1153            'columns' => [
     1154                'id' => ['bigint(20) unsigned', 'NO', null, true],
     1155                'source_id' => ['bigint(20) unsigned', 'NO', null, false],
     1156                'source_record_key' => ['varchar(191)', 'NO', null, false],
     1157                'source_record_hash' => ['char(64)', 'NO', null, false],
     1158                'product_id' => ['bigint(20) unsigned', 'NO', null, false],
     1159                'record_type' => ['varchar(20)', 'NO', 'event', false],
     1160                'period_start' => ['datetime', 'YES', null, false],
     1161                'period_end' => ['datetime', 'YES', null, false],
     1162                'observed_views' => ['bigint(20) unsigned', 'NO', '0', false],
     1163                'applied_views' => ['bigint(20) unsigned', 'NO', '0', false],
     1164                'source_fingerprint' => ['char(64)', 'YES', null, false],
     1165                'status' => ['varchar(20)', 'NO', 'pending', false],
     1166                'created_at' => ['datetime', 'NO', null, false],
     1167                'updated_at' => ['datetime', 'NO', null, false],
     1168            ],
     1169            'indexes' => [
     1170                'PRIMARY' => [true, ['id']],
     1171                'source_record' => [true, ['source_id', 'source_record_hash']],
     1172                'product_id' => [false, ['product_id']],
     1173                'source_product' => [false, ['source_id', 'product_id']],
     1174                'source_fingerprint' => [false, ['source_fingerprint']],
     1175                'product_record_period' => [false, ['product_id', 'record_type', 'period_start', 'period_end']],
     1176                'status' => [false, ['status']],
     1177            ],
     1178        ],
     1179        $this->view_source_reconciliation_table => [
     1180            'columns' => [
     1181                'id' => ['bigint(20) unsigned', 'NO', null, true],
     1182                'event_id' => ['bigint(20) unsigned', 'NO', null, false],
     1183                'status' => ['varchar(30)', 'NO', 'unprocessed', false],
     1184                'reason_code' => ['varchar(100)', 'NO', null, false],
     1185                'reason' => ['text', 'YES', null, false],
     1186                'matched_event_id' => ['bigint(20) unsigned', 'YES', null, false],
     1187                'evidence' => ['longtext', 'YES', null, false],
     1188                'algorithm_version' => ['varchar(32)', 'NO', null, false],
     1189                'created_at' => ['datetime', 'NO', null, false],
     1190                'updated_at' => ['datetime', 'NO', null, false],
     1191            ],
     1192            'indexes' => [
     1193                'PRIMARY' => [true, ['id']],
     1194                'event_id' => [true, ['event_id']],
     1195                'status' => [false, ['status']],
     1196                'matched_event_id' => [false, ['matched_event_id']],
     1197            ],
     1198        ],
     1199        $this->view_source_reconciliation_history_table => [
     1200            'columns' => [
     1201                'id' => ['bigint(20) unsigned', 'NO', null, true],
     1202                'event_id' => ['bigint(20) unsigned', 'NO', null, false],
     1203                'previous_status' => ['varchar(30)', 'YES', null, false],
     1204                'new_status' => ['varchar(30)', 'NO', null, false],
     1205                'reason_code' => ['varchar(100)', 'NO', null, false],
     1206                'reason' => ['text', 'YES', null, false],
     1207                'matched_event_id' => ['bigint(20) unsigned', 'YES', null, false],
     1208                'evidence' => ['longtext', 'YES', null, false],
     1209                'algorithm_version' => ['varchar(32)', 'NO', null, false],
     1210                'created_at' => ['datetime', 'NO', null, false],
     1211            ],
     1212            'indexes' => [
     1213                'PRIMARY' => [true, ['id']],
     1214                'event_id' => [false, ['event_id']],
     1215                'created_at' => [false, ['created_at']],
     1216            ],
     1217        ],
     1218        $this->view_source_failures_table => [
     1219            'columns' => [
     1220                'id' => ['bigint(20) unsigned', 'NO', null, true],
     1221                'source_id' => ['bigint(20) unsigned', 'NO', null, false],
     1222                'origin_key' => ['varchar(100)', 'NO', null, false],
     1223                'source_record_key' => ['varchar(191)', 'NO', null, false],
     1224                'source_record_hash' => ['char(64)', 'NO', null, false],
     1225                'product_id' => ['bigint(20) unsigned', 'NO', '0', false],
     1226                'attempts' => ['int(10) unsigned', 'NO', '0', false],
     1227                'last_error' => ['text', 'NO', null, false],
     1228                'status' => ['varchar(20)', 'NO', 'retry', false],
     1229                'next_retry_at' => ['datetime', 'YES', null, false],
     1230                'retry_checkpoint' => ['longtext', 'YES', null, false],
     1231                'updated_at' => ['datetime', 'NO', null, false],
     1232            ],
     1233            'indexes' => [
     1234                'PRIMARY' => [true, ['id']],
     1235                'source_record' => [true, ['source_id', 'source_record_hash']],
     1236                'status_retry' => [false, ['status', 'next_retry_at']],
     1237                'source_origin' => [false, ['source_id', 'origin_key']],
     1238                'product_id' => [false, ['product_id']],
     1239            ],
     1240        ],
     1241    ];
     1242
     1243    $sql = [
     1244        "CREATE TABLE {$this->view_sources_table} (
     1245            id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     1246            source_key VARCHAR(100) NOT NULL,
     1247            adapter_key VARCHAR(100) NULL DEFAULT NULL,
     1248            display_name VARCHAR(191) NOT NULL,
     1249            adapter_version VARCHAR(32) NOT NULL,
     1250            granularity VARCHAR(20) NOT NULL,
     1251            status VARCHAR(20) NOT NULL DEFAULT 'detected',
     1252            created_at DATETIME NOT NULL,
     1253            updated_at DATETIME NOT NULL,
     1254            PRIMARY KEY  (id),
     1255            UNIQUE KEY source_key (source_key),
     1256            KEY status (status)
     1257        ) ENGINE=InnoDB $collate;",
     1258        "CREATE TABLE {$this->view_source_origins_table} (
     1259            source_id BIGINT(20) UNSIGNED NOT NULL,
     1260            origin_key VARCHAR(100) NOT NULL,
     1261            adapter_key VARCHAR(100) NOT NULL,
     1262            capabilities LONGTEXT NULL DEFAULT NULL,
     1263            snapshot_data LONGTEXT NULL DEFAULT NULL,
     1264            checkpoint_data LONGTEXT NULL DEFAULT NULL,
     1265            detection_status VARCHAR(20) NOT NULL DEFAULT 'unknown',
     1266            status VARCHAR(20) NOT NULL DEFAULT 'pending',
     1267            last_error TEXT NULL DEFAULT NULL,
     1268            started_at DATETIME NULL DEFAULT NULL,
     1269            completed_at DATETIME NULL DEFAULT NULL,
     1270            updated_at DATETIME NOT NULL,
     1271            PRIMARY KEY  (source_id, origin_key),
     1272            KEY status (status)
     1273        ) ENGINE=InnoDB $collate;",
     1274        "CREATE TABLE {$this->view_source_state_table} (
     1275            source_id BIGINT(20) UNSIGNED NOT NULL,
     1276            snapshot_data LONGTEXT NULL DEFAULT NULL,
     1277            checkpoint_data LONGTEXT NULL DEFAULT NULL,
     1278            status VARCHAR(20) NOT NULL DEFAULT 'pending',
     1279            last_error TEXT NULL DEFAULT NULL,
     1280            started_at DATETIME NULL DEFAULT NULL,
     1281            completed_at DATETIME NULL DEFAULT NULL,
     1282            updated_at DATETIME NOT NULL,
     1283            PRIMARY KEY  (source_id),
     1284            KEY status (status)
     1285        ) ENGINE=InnoDB $collate;",
     1286        "CREATE TABLE {$this->view_source_events_table} (
     1287            id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     1288            source_id BIGINT(20) UNSIGNED NOT NULL,
     1289            source_record_key VARCHAR(191) NOT NULL,
     1290            source_record_hash CHAR(64) NOT NULL,
     1291            product_id BIGINT(20) UNSIGNED NOT NULL,
     1292            record_type VARCHAR(20) NOT NULL DEFAULT 'event',
     1293            period_start DATETIME NULL DEFAULT NULL,
     1294            period_end DATETIME NULL DEFAULT NULL,
     1295            observed_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     1296            applied_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     1297            source_fingerprint CHAR(64) NULL DEFAULT NULL,
     1298            status VARCHAR(20) NOT NULL DEFAULT 'pending',
     1299            created_at DATETIME NOT NULL,
     1300            updated_at DATETIME NOT NULL,
     1301            PRIMARY KEY  (id),
     1302            UNIQUE KEY source_record (source_id, source_record_hash),
     1303            KEY product_id (product_id),
     1304            KEY source_product (source_id, product_id),
     1305            KEY source_fingerprint (source_fingerprint),
     1306            KEY product_record_period (product_id, record_type, period_start, period_end),
     1307            KEY status (status)
     1308        ) ENGINE=InnoDB $collate;",
     1309        "CREATE TABLE {$this->view_source_failures_table} (
     1310            id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     1311            source_id BIGINT(20) UNSIGNED NOT NULL,
     1312            origin_key VARCHAR(100) NOT NULL,
     1313            source_record_key VARCHAR(191) NOT NULL,
     1314            source_record_hash CHAR(64) NOT NULL,
     1315            product_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     1316            attempts INT(10) UNSIGNED NOT NULL DEFAULT 0,
     1317            last_error TEXT NOT NULL,
     1318            status VARCHAR(20) NOT NULL DEFAULT 'retry',
     1319            next_retry_at DATETIME NULL DEFAULT NULL,
     1320            retry_checkpoint LONGTEXT NULL DEFAULT NULL,
     1321            updated_at DATETIME NOT NULL,
     1322            PRIMARY KEY  (id),
     1323            UNIQUE KEY source_record (source_id, source_record_hash),
     1324            KEY status_retry (status, next_retry_at),
     1325            KEY source_origin (source_id, origin_key),
     1326            KEY product_id (product_id)
     1327        ) ENGINE=InnoDB $collate;",
     1328        "CREATE TABLE {$this->view_source_reconciliation_table} (
     1329            id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     1330            event_id BIGINT(20) UNSIGNED NOT NULL,
     1331            status VARCHAR(30) NOT NULL DEFAULT 'unprocessed',
     1332            reason_code VARCHAR(100) NOT NULL,
     1333            reason TEXT NULL DEFAULT NULL,
     1334            matched_event_id BIGINT(20) UNSIGNED NULL DEFAULT NULL,
     1335            evidence LONGTEXT NULL DEFAULT NULL,
     1336            algorithm_version VARCHAR(32) NOT NULL,
     1337            created_at DATETIME NOT NULL,
     1338            updated_at DATETIME NOT NULL,
     1339            PRIMARY KEY  (id),
     1340            UNIQUE KEY event_id (event_id),
     1341            KEY status (status),
     1342            KEY matched_event_id (matched_event_id)
     1343        ) ENGINE=InnoDB $collate;",
     1344        "CREATE TABLE {$this->view_source_reconciliation_history_table} (
     1345            id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     1346            event_id BIGINT(20) UNSIGNED NOT NULL,
     1347            previous_status VARCHAR(30) NULL DEFAULT NULL,
     1348            new_status VARCHAR(30) NOT NULL,
     1349            reason_code VARCHAR(100) NOT NULL,
     1350            reason TEXT NULL DEFAULT NULL,
     1351            matched_event_id BIGINT(20) UNSIGNED NULL DEFAULT NULL,
     1352            evidence LONGTEXT NULL DEFAULT NULL,
     1353            algorithm_version VARCHAR(32) NOT NULL,
     1354            created_at DATETIME NOT NULL,
     1355            PRIMARY KEY  (id),
     1356            KEY event_id (event_id),
     1357            KEY created_at (created_at)
     1358        ) ENGINE=InnoDB $collate;",
     1359    ];
     1360
     1361    $fail = static function ($message) {
     1362        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Preserve an actionable migration failure in the PHP error log when schema setup cannot complete.
     1363        error_log('[Statora Plus] View-source schema migration failed: ' . $message);
     1364        return false;
     1365    };
     1366
     1367    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     1368
     1369    try {
     1370        foreach ($sql as $statement) {
     1371            dbDelta($statement);
     1372        }
     1373    } catch (Throwable $e) {
     1374        return $fail($e->getMessage());
     1375    }
     1376
     1377    // Convert legacy detection values out of the processing-status column.
     1378    // Existing processing states are preserved; only legacy detection labels are migrated.
     1379    $legacy_status_migration = statpl_db_query(
     1380        "UPDATE {$this->view_source_origins_table}
     1381         SET detection_status = CASE
     1382                 WHEN status IN ('available', 'missing_table', 'incompatible_schema', 'database_error')
     1383                     THEN status
     1384                 ELSE 'unknown'
     1385             END,
     1386             status = 'pending'
     1387         WHERE status IN ('detected', 'available', 'missing_table', 'incompatible_schema', 'database_error')"
     1388    );
     1389
     1390    if ($legacy_status_migration === false || $wpdb->last_error !== '') {
     1391        return $fail('Could not migrate legacy origin detection statuses: ' . $wpdb->last_error);
     1392    }
     1393
     1394    foreach ($tables as $table) {
     1395        // The migration metadata is the allowlist for identifiers used by the
     1396        // SHOW COLUMNS / SHOW INDEX statements below. Reject anything outside it.
     1397        if (
     1398            !is_string($table)
     1399            || !array_key_exists($table, $definitions)
     1400            || !preg_match('/^[A-Za-z0-9_]+$/D', $table)
     1401        ) {
     1402            return $fail('Invalid internal table identifier during schema verification.');
     1403        }
     1404
     1405        $found_table = statpl_db_get_var(
     1406            statpl_db_prepare(
     1407                'SHOW TABLES LIKE %s',
     1408                $wpdb->esc_like($table)
     1409            )
     1410        );
     1411
     1412        if ($wpdb->last_error !== '') {
     1413            return $fail('Could not inspect table existence for ' . $table . ': ' . $wpdb->last_error);
     1414        }
     1415
     1416        if ($found_table !== $table) {
     1417            return $fail('Required table was not created: ' . $table);
     1418        }
     1419
     1420        $table_status = statpl_db_get_row(
     1421            statpl_db_prepare(
     1422                'SHOW TABLE STATUS LIKE %s',
     1423                $wpdb->esc_like($table)
     1424            ),
     1425            ARRAY_A
     1426        );
     1427
     1428        if ($wpdb->last_error !== '') {
     1429            return $fail('Could not inspect table engine for ' . $table . ': ' . $wpdb->last_error);
     1430        }
     1431
     1432        if (!$table_status || !isset($table_status['Engine']) || strcasecmp($table_status['Engine'], 'InnoDB') !== 0) {
     1433            return $fail('Table engine is not InnoDB: ' . $table);
     1434        }
     1435
     1436        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Identifier is built from the trusted WordPress prefix and fixed plugin suffix.
     1437        $column_rows = statpl_db_get_results("SHOW COLUMNS FROM `{$table}`", ARRAY_A);
     1438
     1439        if ($wpdb->last_error !== '') {
     1440            return $fail('Could not inspect columns for ' . $table . ': ' . $wpdb->last_error);
     1441        }
     1442
     1443        if (!is_array($column_rows)) {
     1444            return $fail('Invalid column metadata for ' . $table);
     1445        }
     1446
     1447        $actual_columns = [];
     1448        foreach ($column_rows as $column) {
     1449            $actual_columns[$column['Field']] = $column;
     1450        }
     1451
     1452        $expected_columns = $definitions[$table]['columns'];
     1453
     1454        if (count($actual_columns) !== count($expected_columns)) {
     1455            return $fail('Unexpected column count in ' . $table);
     1456        }
     1457
     1458        foreach ($expected_columns as $name => $expected) {
     1459            if (!isset($actual_columns[$name])) {
     1460                return $fail('Missing column ' . $table . '.' . $name);
     1461            }
     1462
     1463            $actual = $actual_columns[$name];
     1464            $actual_type = strtolower((string) $actual['Type']);
     1465            $actual_null = strtoupper((string) $actual['Null']);
     1466            $actual_default = $actual['Default'];
     1467            $actual_extra = strtolower(trim((string) $actual['Extra']));
     1468            $actual_extra = preg_replace('/\\s+/', ' ', $actual_extra);
     1469            $expected_extra = $expected[3] ? 'auto_increment' : '';
     1470
     1471            if ($actual_type !== $expected[0]) {
     1472                return $fail('Wrong type for ' . $table . '.' . $name . ': ' . $actual_type);
     1473            }
     1474
     1475            if ($actual_null !== $expected[1]) {
     1476                return $fail('Wrong NULL definition for ' . $table . '.' . $name);
     1477            }
     1478
     1479            if ($expected[2] === null) {
     1480                if ($actual_default !== null) {
     1481                    return $fail('Unexpected default for ' . $table . '.' . $name);
     1482                }
     1483            } elseif ((string) $actual_default !== $expected[2]) {
     1484                return $fail('Wrong default for ' . $table . '.' . $name);
     1485            }
     1486
     1487            if ($actual_extra !== $expected_extra) {
     1488                return $fail('Wrong Extra definition for ' . $table . '.' . $name . ': ' . $actual_extra);
     1489            }
     1490        }
     1491
     1492        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Identifier is built from the trusted WordPress prefix and fixed plugin suffix.
     1493        $index_rows = statpl_db_get_results("SHOW INDEX FROM `{$table}`", ARRAY_A);
     1494
     1495        if ($wpdb->last_error !== '') {
     1496            return $fail('Could not inspect indexes for ' . $table . ': ' . $wpdb->last_error);
     1497        }
     1498
     1499        if (!is_array($index_rows)) {
     1500            return $fail('Invalid index metadata for ' . $table);
     1501        }
     1502
     1503        $actual_indexes = [];
     1504        foreach ($index_rows as $index) {
     1505            $key_name = $index['Key_name'];
     1506
     1507            if (!isset($actual_indexes[$key_name])) {
     1508                $actual_indexes[$key_name] = [
     1509                    'unique' => ((int) $index['Non_unique'] === 0),
     1510                    'columns' => [],
     1511                ];
     1512            } elseif ($actual_indexes[$key_name]['unique'] !== ((int) $index['Non_unique'] === 0)) {
     1513                return $fail('Inconsistent uniqueness metadata for index ' . $table . '.' . $key_name);
     1514            }
     1515
     1516            $sequence = (string) $index['Seq_in_index'];
     1517            if (!preg_match('/^[1-9][0-9]*$/D', $sequence)) {
     1518                return $fail('Invalid Seq_in_index for ' . $table . '.' . $key_name);
     1519            }
     1520
     1521            $sequence = (int) $sequence;
     1522            if (isset($actual_indexes[$key_name]['columns'][$sequence])) {
     1523                return $fail('Duplicate Seq_in_index for ' . $table . '.' . $key_name);
     1524            }
     1525
     1526            if (!array_key_exists('Sub_part', $index) || $index['Sub_part'] !== null) {
     1527                return $fail('Unexpected index prefix length for ' . $table . '.' . $key_name);
     1528            }
     1529
     1530            $actual_indexes[$key_name]['columns'][$sequence] = $index['Column_name'];
     1531        }
     1532
     1533        foreach ($actual_indexes as $key_name => &$index_data) {
     1534            ksort($index_data['columns']);
     1535            $positions = array_keys($index_data['columns']);
     1536
     1537            if ($positions !== range(1, count($positions))) {
     1538                return $fail('Non-contiguous Seq_in_index for ' . $table . '.' . $key_name);
     1539            }
     1540
     1541            $index_data['columns'] = array_values($index_data['columns']);
     1542        }
     1543        unset($index_data);
     1544
     1545        $expected_indexes = $definitions[$table]['indexes'];
     1546
     1547        if (count($actual_indexes) !== count($expected_indexes)) {
     1548            return $fail('Unexpected index count in ' . $table);
     1549        }
     1550
     1551        foreach ($expected_indexes as $name => $expected) {
     1552            if (!isset($actual_indexes[$name])) {
     1553                return $fail('Missing index ' . $table . '.' . $name);
     1554            }
     1555
     1556            if (
     1557                $actual_indexes[$name]['unique'] !== $expected[0]
     1558                || $actual_indexes[$name]['columns'] !== $expected[1]
     1559            ) {
     1560                return $fail('Incorrect index definition for ' . $table . '.' . $name);
     1561            }
     1562        }
     1563    }
     1564
     1565    update_option('statpl_view_source_schema_version', $schema_version, false);
     1566
     1567    if (get_option('statpl_view_source_schema_version', '') !== $schema_version) {
     1568        return $fail('Could not persist the verified view-source schema version.');
     1569    }
     1570
     1571    return true;
     1572}
     1573
     1574    /**
     1575     * Reclassify legacy aggregate matches that relied on the synthetic
     1576     * product/date/count fingerprint. This is audit-only: totals and
     1577     * applied_views are deliberately not modified because the old records
     1578     * cannot prove whether the underlying views were actually duplicated.
     1579     */
     1580    private function migrate_legacy_aggregate_fingerprint_matches() {
     1581        global $wpdb;
     1582
     1583        $complete_option = 'statpl_legacy_aggregate_fp_migration_v1_complete';
     1584        $cursor_option = 'statpl_legacy_aggregate_fp_migration_v1_cursor';
     1585        if (get_option($complete_option, '') === '1') {
     1586            return true;
     1587        }
     1588
     1589        $lock_name = 'statpl_legacy_agg_' . substr(hash('sha256', $wpdb->prefix), 0, 40);
     1590        $migration_lock = $this->acquire_generic_advisory_lock($lock_name);
     1591        if (empty($migration_lock['acquired'])) {
     1592            $this->schedule_legacy_aggregate_reconciliation_migration(60);
     1593            return false;
     1594        }
     1595
     1596        $batch_size = 100;
     1597        $cursor = max(0, (int) get_option($cursor_option, 0));
     1598        $processed = 0;
     1599        $failed = false;
     1600        $migration_transaction_started = false;
     1601        $migration_transaction_committed = false;
     1602
     1603        try {
     1604            $rows = statpl_db_get_results(statpl_db_prepare(
     1605                "SELECT r.event_id, r.status, r.reason_code, r.reason, r.matched_event_id,
     1606                        r.evidence, r.algorithm_version,
     1607                        e.source_id, e.product_id, e.record_type, e.observed_views,
     1608                        e.period_start, e.period_end, e.source_fingerprint,
     1609                        m.source_id AS matched_source_id,
     1610                        m.record_type AS matched_record_type,
     1611                        m.source_fingerprint AS matched_source_fingerprint
     1612                 FROM {$this->view_source_reconciliation_table} r
     1613                 INNER JOIN {$this->view_source_events_table} e ON e.id = r.event_id
     1614                 INNER JOIN {$this->view_source_events_table} m ON m.id = r.matched_event_id
     1615                 WHERE r.event_id > %d
     1616                   AND r.status = 'matched_exact'
     1617                   AND r.reason_code = 'shared_fingerprint_exact_match'
     1618                   AND e.record_type = 'aggregate'
     1619                   AND m.record_type = 'aggregate'
     1620                   AND e.source_id <> m.source_id
     1621                 ORDER BY r.event_id ASC
     1622                 LIMIT %d",
     1623                $cursor,
     1624                $batch_size
     1625            ), ARRAY_A);
     1626
     1627            if (!is_array($rows)) {
     1628                $failed = true;
     1629                return false;
     1630            }
     1631
     1632            foreach ($rows as $row) {
     1633                $event_id = (int) $row['event_id'];
     1634                $expected_fingerprint = '';
     1635                if ($row['period_start'] !== null && $row['period_end'] !== null) {
     1636                    $expected_fingerprint = hash(
     1637                        'sha256',
     1638                        "statpl-product-day-aggregate-v1\0"
     1639                        . (int) $row['product_id'] . "\0"
     1640                        . (string) $row['period_start'] . "\0"
     1641                        . (string) $row['period_end'] . "\0"
     1642                        . (int) $row['observed_views']
     1643                    );
     1644                }
     1645
     1646                $is_legacy_synthetic_match = $expected_fingerprint !== ''
     1647                    && is_string($row['source_fingerprint'])
     1648                    && is_string($row['matched_source_fingerprint'])
     1649                    && hash_equals($expected_fingerprint, strtolower($row['source_fingerprint']))
     1650                    && hash_equals($expected_fingerprint, strtolower($row['matched_source_fingerprint']));
     1651
     1652                if ($is_legacy_synthetic_match) {
     1653                    $evidence = json_decode((string) $row['evidence'], true);
     1654                    if (!is_array($evidence)) {
     1655                        $evidence = ['legacy_evidence_raw' => (string) $row['evidence']];
     1656                    }
     1657                    $evidence['legacy_reclassification'] = [
     1658                        'migration' => 'legacy_aggregate_fingerprint_v1',
     1659                        'previous_status' => 'matched_exact',
     1660                        'new_status' => 'legacy_exact_unverified',
     1661                        'reason' => 'The previous synthetic fingerprint represented product, date and count, not a shared view-event identity. The old exact-match decision is therefore unverified.',
     1662                        'totals_changed' => false,
     1663                        'applied_views_changed' => false,
     1664                    ];
     1665                    $encoded_evidence = wp_json_encode($evidence);
     1666                    if (!is_string($encoded_evidence)) {
     1667                        $failed = true;
     1668                        return false;
     1669                    }
     1670
     1671                    if (!$this->generic_advisory_lock_session_is_current($migration_lock)) {
     1672                        $failed = true;
     1673                        return false;
     1674                    }
     1675
     1676                    $migration_transaction_started = false;
     1677                    $migration_transaction_committed = false;
     1678                    $transaction_started = (statpl_db_query('START TRANSACTION') !== false);
     1679                    if (!$transaction_started) {
     1680                        $failed = true;
     1681                        return false;
     1682                    }
     1683
     1684                    $migration_transaction_started = true;
     1685                    if (!$this->generic_advisory_lock_session_is_current($migration_lock)) {
     1686                        // Lock ownership and connection identity are separate checks.
     1687                        // Roll back only when this is still the original DB session.
     1688                        if ($this->generic_db_session_matches_lock($migration_lock)) {
     1689                            statpl_db_query('ROLLBACK');
     1690                            $migration_transaction_started = false;
     1691                        }
     1692                        $failed = true;
     1693                        return false;
     1694                    }
     1695                    $updated = statpl_db_update(
     1696                        $this->view_source_reconciliation_table,
     1697                        [
     1698                            'status' => 'legacy_exact_unverified',
     1699                            'reason_code' => 'legacy_aggregate_fingerprint_unverified',
     1700                            'reason' => 'Legacy exact match based on product/date/count only; actual shared view identity was not established. Totals were intentionally left unchanged pending a controlled reconciliation decision.',
     1701                            'evidence' => $encoded_evidence,
     1702                            'algorithm_version' => '1.2.0',
     1703                            'updated_at' => current_time('mysql'),
     1704                        ],
     1705                        ['event_id' => $event_id, 'status' => 'matched_exact']
     1706                    );
     1707                    $history = $updated === 1 ? statpl_db_insert(
     1708                        $this->view_source_reconciliation_history_table,
     1709                        [
     1710                            'event_id' => $event_id,
     1711                            'previous_status' => 'matched_exact',
     1712                            'new_status' => 'legacy_exact_unverified',
     1713                            'reason_code' => 'legacy_aggregate_fingerprint_unverified',
     1714                            'reason' => 'Reclassified because the legacy aggregate fingerprint did not prove shared view-event identity. No totals or applied_views were changed.',
     1715                            'matched_event_id' => (int) $row['matched_event_id'],
     1716                            'evidence' => $encoded_evidence,
     1717                            'algorithm_version' => '1.2.0',
     1718                            'created_at' => current_time('mysql'),
     1719                        ]
     1720                    ) : false;
     1721
     1722                    if ($updated !== 1 || $history !== 1
     1723                        || !$this->generic_advisory_lock_session_is_current($migration_lock)
     1724                        || statpl_db_query('COMMIT') === false) {
     1725                        // Roll back only if the original lock-owning session is still current.
     1726                        // A reconnect implicitly abandons the old transaction; never issue
     1727                        // transaction control against an unverified replacement session.
     1728                        if ($migration_transaction_started && $this->generic_db_session_matches_lock($migration_lock)) {
     1729                            statpl_db_query('ROLLBACK');
     1730                            $migration_transaction_started = false;
     1731                        }
     1732                        $failed = true;
     1733                        return false;
     1734                    }
     1735                    $migration_transaction_committed = true;
     1736                    $migration_transaction_started = false;
     1737                }
     1738
     1739                $cursor = $event_id;
     1740                // Persist the cursor only while this exact MySQL session still owns
     1741                // the migration lock. A PHP-level check followed by update_option()
     1742                // would leave a reconnect race between the check and the write.
     1743                $cursor_persisted = statpl_db_query(statpl_db_prepare(
     1744                    "INSERT INTO {$wpdb->options} (option_name, option_value, autoload)
     1745                     SELECT %s, %s, 'no'
     1746                     WHERE IS_USED_LOCK(%s) = CONNECTION_ID()
     1747                     ON DUPLICATE KEY UPDATE option_value = IF(
     1748                         IS_USED_LOCK(%s) = CONNECTION_ID(),
     1749                         VALUES(option_value),
     1750                         option_value
     1751                     )",
     1752                    $cursor_option,
     1753                    (string) $cursor,
     1754                    $lock_name,
     1755                    $lock_name
     1756                ));
     1757                if (!in_array($cursor_persisted, [0, 1, 2], true)
     1758                    || !$this->generic_advisory_lock_session_is_current($migration_lock)) {
     1759                    $failed = true;
     1760                    return false;
     1761                }
     1762                $persisted_cursor = statpl_db_get_var(statpl_db_prepare(
     1763                    "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
     1764                    $cursor_option
     1765                ));
     1766                if (!empty($wpdb->last_error) || (int) $persisted_cursor !== $cursor) {
     1767                    $failed = true;
     1768                    return false;
     1769                }
     1770                $processed++;
     1771            }
     1772
     1773            if (count($rows) < $batch_size) {
     1774                // Completion is also lock-owner guarded so a stale/reconnected
     1775                // worker cannot mark an unfinished migration complete.
     1776                $complete_persisted = statpl_db_query(statpl_db_prepare(
     1777                    "INSERT INTO {$wpdb->options} (option_name, option_value, autoload)
     1778                     SELECT %s, '1', 'no'
     1779                     WHERE IS_USED_LOCK(%s) = CONNECTION_ID()
     1780                     ON DUPLICATE KEY UPDATE option_value = IF(
     1781                         IS_USED_LOCK(%s) = CONNECTION_ID(), '1', option_value
     1782                     )",
     1783                    $complete_option,
     1784                    $lock_name,
     1785                    $lock_name
     1786                ));
     1787                $persisted_completion = statpl_db_get_var(statpl_db_prepare(
     1788                    "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
     1789                    $complete_option
     1790                ));
     1791                $completion_verified = in_array($complete_persisted, [0, 1, 2], true)
     1792                    && $this->generic_advisory_lock_session_is_current($migration_lock)
     1793                    && empty($wpdb->last_error)
     1794                    && (string) $persisted_completion === '1';
     1795                if (!$completion_verified) {
     1796                    $failed = true;
     1797                }
     1798                return $completion_verified;
     1799            }
     1800
     1801            $this->schedule_legacy_aggregate_reconciliation_migration(30);
     1802            return true;
     1803        } catch (Throwable $exception) {
     1804            if (defined('WP_DEBUG') && WP_DEBUG) {
     1805                $this->debug_log('[Statora Plus] Legacy aggregate reconciliation audit migration failed: ' . $exception->getMessage());
     1806            }
     1807            $failed = true;
     1808            return false;
     1809        } finally {
     1810            if ($migration_transaction_started && !$migration_transaction_committed
     1811                && $this->generic_db_session_matches_lock($migration_lock)) {
     1812                statpl_db_query('ROLLBACK');
     1813                $migration_transaction_started = false;
     1814            }
     1815            $this->release_generic_advisory_lock($migration_lock, 'legacy_aggregate_fingerprint_migration');
     1816            if ($failed) {
     1817                $this->schedule_legacy_aggregate_reconciliation_migration(60);
     1818                if (defined('WP_DEBUG') && WP_DEBUG) {
     1819                    $this->debug_log('[Statora Plus] Legacy aggregate reconciliation audit migration was rescheduled after a failed batch.');
     1820                }
     1821            }
     1822        }
     1823    }
     1824
     1825    /**
     1826     * Schedule the audit migration outside the normal frontend initialization path.
     1827     * Action Scheduler is preferred; WP-Cron remains the compatibility fallback.
     1828     */
     1829    public function schedule_legacy_aggregate_reconciliation_migration($delay = 15) {
     1830        if (get_option('statpl_legacy_aggregate_fp_migration_v1_complete', '') === '1') {
     1831            return;
     1832        }
     1833
     1834        $delay = max(5, (int) $delay);
     1835        $hook = 'statpl_reconcile_legacy_aggregate_fingerprints';
     1836        $args = [];
     1837        $group = 'statpl-plus';
     1838
     1839        if (function_exists('as_schedule_single_action') && !did_action('action_scheduler_init')) {
     1840            add_action('action_scheduler_init', [$this, 'schedule_legacy_aggregate_reconciliation_migration'], 10, 0);
     1841            return;
     1842        }
     1843
     1844        if (
     1845            function_exists('as_has_scheduled_action')
     1846            && did_action('action_scheduler_init')
     1847            && as_has_scheduled_action($hook, $args, $group)
     1848        ) {
     1849            return;
     1850        }
     1851
     1852        if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) {
     1853            $action_id = as_schedule_single_action(time() + $delay, $hook, $args, $group);
     1854            if ($action_id) {
     1855                return;
     1856            }
     1857        }
     1858
     1859        if (!wp_next_scheduled($hook, $args)) {
     1860            wp_schedule_single_event(time() + $delay, $hook, $args);
     1861        }
     1862    }
     1863
     1864    public function init_legacy_aggregate_reconciliation_migration() {
     1865        $this->migrate_legacy_aggregate_fingerprint_matches();
     1866    }
     1867
     1868     public function init() {
     1869        // Register runtime hooks before touching the database. If MySQL is
     1870        // temporarily unavailable during plugins_loaded, frontend/AJAX and
     1871        // WooCommerce hooks must still exist for the remainder of this request.
    1101872        add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend']);
    1111873        add_action('wp_ajax_statpl_record_view', [$this, 'record_view']);
    … …  
    1131875        add_action('admin_menu', [$this, 'admin_menu']);
    1141876        add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_styles']);
     1877        add_action('admin_enqueue_scripts', [$this, 'enqueue_deactivation_prompt']);
    1151878        add_action('admin_notices', [$this, 'woocommerce_notice']);
     1879        add_action('admin_footer', [$this, 'enqueue_historical_notice_script']);
    1161880        add_action('woocommerce_order_status_cancelled', [$this, 'record_cancel_event'], 10, 2);
    1171881        add_action('woocommerce_order_refunded', [$this, 'record_refund_event'], 10, 2);
    118         // Invalidate cached aggregates when order data or status changes.
    1191882        foreach (['woocommerce_new_order', 'woocommerce_update_order', 'woocommerce_order_status_changed', 'woocommerce_order_payment_status_changed', 'woocommerce_saved_order_items'] as $hook) {
    1201883            add_action($hook, [$this, 'invalidate_order_stats_cache'], 10, 3);
    1211884        }
    1221885        add_action('woocommerce_order_refunded', [$this, 'invalidate_order_stats_cache'], 20, 2);
    123         // A refund changes net quantity/revenue in the order snapshot as well as refund-event history.
    1241886        add_action('woocommerce_order_refunded', [$this, 'queue_order_snapshot'], 30, 2);
    1251887        add_action('woocommerce_before_delete_order', [$this, 'delete_order_snapshot'], 10, 1);
    … …  
    1281890        }
    1291891        add_action('statpl_import_batch', [$this, 'run_import_batch']);
     1892        add_action('statpl_view_import_batch', [$this, 'run_slimstat_view_import_batch']);
     1893        add_action('statpl_generic_view_import_batch', [$this, 'run_generic_historical_view_import_batch'], 10, 2);
     1894        add_action('statpl_generic_view_snapshot_retry', [$this, 'retry_generic_historical_view_snapshot'], 10, 2);
     1895        add_action('statpl_generic_view_retry_failed_records', [$this, 'retry_generic_historical_view_failures'], 10, 2);
    1301896        add_action('statpl_sync_order', [$this, 'sync_order_snapshot'], 10, 1);
    131         if (!get_option('statpl_import_complete')) { $this->schedule_import_batch(); }
    132     }
     1897        add_action('statpl_reconcile_legacy_aggregate_fingerprints', [$this, 'init_legacy_aggregate_reconciliation_migration']);
     1898
     1899        global $wpdb;
     1900        // Do not interpret failed option/table reads as missing state. A simple
     1901        // round-trip probe distinguishes a usable connection from a DB outage.
     1902        try {
     1903            $database_ready = isset($wpdb) && is_object($wpdb)
     1904                && (string) statpl_db_get_var('SELECT 1') === '1'
     1905                && empty($wpdb->last_error);
     1906        } catch (Throwable $exception) {
     1907            $database_ready = false;
     1908        }
     1909        if (!$database_ready) {
     1910            if (defined('WP_DEBUG') && WP_DEBUG) {
     1911                $this->debug_log('[Statora Plus] Database unavailable during initialization; schema upgrades and historical discovery will retry on the next request. Runtime hooks remain registered.');
     1912            }
     1913            return;
     1914        }
     1915
     1916        try {
     1917            if (get_option('statpl_db_version') !== self::VERSION) {
     1918                // Core tables must be verified before historical schema upgrades
     1919                // or adapter discovery can run. A failed dbDelta pass must leave
     1920                // the version marker unchanged so the next request retries it.
     1921                if (!$this->activate()) {
     1922                    if (defined('WP_DEBUG') && WP_DEBUG) {
     1923                        $this->debug_log('[Statora Plus] Core schema verification failed; initialization will retry on the next request.');
     1924                    }
     1925                    return;
     1926                }
     1927            }
     1928            $this->maybe_upgrade_view_import_schema();
     1929            $this->maybe_upgrade_view_import_failures_schema();
     1930            $this->maybe_upgrade_view_import_state_schema();
     1931            $this->maybe_upgrade_view_import_events_schema();
     1932            $this->maybe_upgrade_view_source_schema();
     1933            $this->maybe_upgrade_historical_import_report_schema();
     1934        } catch (Throwable $exception) {
     1935            if (defined('WP_DEBUG') && WP_DEBUG) {
     1936                $this->debug_log('[Statora Plus] Schema initialization interrupted; version markers remain subject to verification and initialization will retry on the next request.');
     1937            }
     1938            return;
     1939        }
     1940
     1941        // Schema helpers intentionally leave their version markers unchanged
     1942        // when verification fails. Do not let discovery/workers query partially
     1943        // initialized tables; the next request will retry the outstanding steps.
     1944        $historical_schema_ready =
     1945            get_option('statpl_view_import_schema_version', '') === self::VIEW_IMPORT_SCHEMA_VERSION
     1946            && get_option('statpl_view_import_failures_schema_version', '') === self::VIEW_IMPORT_FAILURES_SCHEMA_VERSION
     1947            && get_option('statpl_view_import_state_schema_version', '') === self::VIEW_IMPORT_STATE_SCHEMA_VERSION
     1948            && get_option('statpl_view_import_events_schema_version', '') === self::VIEW_IMPORT_EVENTS_SCHEMA_VERSION
     1949            && get_option('statpl_view_source_schema_version', '') === self::VIEW_SOURCE_SCHEMA_VERSION
     1950            && get_option('statpl_history_report_schema_version', '') === self::HISTORY_REPORT_SCHEMA_VERSION;
     1951        if (!$historical_schema_ready) {
     1952            if (defined('WP_DEBUG') && WP_DEBUG) {
     1953                $this->debug_log('[Statora Plus] Historical schema is not fully verified; historical discovery and recovery are deferred until a later request.');
     1954            }
     1955            return;
     1956        }
     1957
     1958        // Schedule the audit migration outside the normal request path. The worker
     1959        // uses bounded batches and a connection-scoped lock for safe continuation.
     1960        $this->schedule_legacy_aggregate_reconciliation_migration(15);
     1961
     1962        // Keep each startup subsystem isolated. A scheduler or adapter-provider
     1963        // failure in one subsystem must not prevent the remaining durable work
     1964        // from being discovered and recovered on this request.
     1965        if (!get_option('statpl_import_complete')) {
     1966            try {
     1967                $this->schedule_import_batch();
     1968            } catch (Throwable $exception) {
     1969                if (defined('WP_DEBUG') && WP_DEBUG) {
     1970                    $this->debug_log('[Statora Plus] Core import scheduling failed; it will be retried on a later request.');
     1971                }
     1972            }
     1973        }
     1974
     1975        // SlimStat remains on its existing, independent initialization path.
     1976        // Contain unexpected failures at the call site so they cannot block
     1977        // generic adapter discovery or recovery.
     1978        try {
     1979            $slimstat_result = $this->initialize_slimstat_view_import();
     1980            if (isset($slimstat_result['status']) && $slimstat_result['status'] !== 'already_initialized') {
     1981                update_option('statpl_history_slimstat_probe', sanitize_key((string) $slimstat_result['status']), false);
     1982            } elseif (($slimstat_result['status'] ?? '') === 'already_initialized') {
     1983                $slimstat_state_status = statpl_db_get_var(statpl_db_prepare(
     1984                    "SELECT status FROM {$this->view_import_state_table} WHERE source = %s", 'slimstat'
     1985                ));
     1986                if (in_array((string) $slimstat_state_status, ['pending','running'], true)) {
     1987                    $slimstat_probe_result = $this->get_slimstat_view_snapshot();
     1988                    update_option('statpl_history_slimstat_probe', sanitize_key((string) ($slimstat_probe_result['status'] ?? 'database_error')), false);
     1989                }
     1990            }
     1991            if (
     1992                is_array($slimstat_result)
     1993                && isset($slimstat_result['status'])
     1994                && in_array($slimstat_result['status'], ['initialized', 'already_initialized'], true)
     1995            ) {
     1996                try {
     1997                    $this->schedule_slimstat_view_import_batch();
     1998                } catch (Throwable $exception) {
     1999                    if (defined('WP_DEBUG') && WP_DEBUG) {
     2000                        $this->debug_log('[Statora Plus] SlimStat import scheduling failed; its existing recovery path will retry.');
     2001                    }
     2002                }
     2003            }
     2004        } catch (Throwable $exception) {
     2005            if (defined('WP_DEBUG') && WP_DEBUG) {
     2006                $this->debug_log('[Statora Plus] SlimStat initialization failed; generic historical sources will continue independently.');
     2007            }
     2008        }
     2009
     2010        // Discovery failure must not suppress recovery of already persisted
     2011        // work. Conversely, recovery is attempted even if discovery throws.
     2012        try {
     2013            $generic_result = $this->discover_historical_view_adapters();
     2014            update_option('statpl_history_discovery_status', sanitize_key((string) ($generic_result['status'] ?? 'unknown')), false);
     2015            if (!empty($generic_result['registered'])) {
     2016                try {
     2017                    $this->schedule_generic_historical_view_import_batch(15);
     2018                } catch (Throwable $exception) {
     2019                    if (defined('WP_DEBUG') && WP_DEBUG) {
     2020                        $this->debug_log('[Statora Plus] Generic historical import scheduling failed; durable work will be recovered later.');
     2021                    }
     2022                }
     2023            }
     2024        } catch (Throwable $exception) {
     2025            if (defined('WP_DEBUG') && WP_DEBUG) {
     2026                $this->debug_log('[Statora Plus] Generic adapter discovery failed; persisted historical work recovery will still run.');
     2027            }
     2028        }
     2029
     2030        if (get_option('statpl_retained_deactivation_at', '') !== '') {
     2031            $gap_result = $this->resume_retained_history_gap();
     2032            if (($gap_result['status'] ?? '') === 'reset') {
     2033                try {
     2034                    $this->schedule_slimstat_view_import_batch(15);
     2035                } catch (Throwable $exception) {
     2036                    if (defined('WP_DEBUG') && WP_DEBUG) {
     2037                        $this->debug_log('[Statora Plus] Retained history recovery scheduling failed; it will be retried on a later request.');
     2038                    }
     2039                }
     2040            }
     2041        }
     2042
     2043        try {
     2044            $this->recover_unscheduled_generic_historical_work();
     2045        } catch (Throwable $exception) {
     2046            if (defined('WP_DEBUG') && WP_DEBUG) {
     2047                $this->debug_log('[Statora Plus] Generic historical work recovery failed; it will be retried on a later request.');
     2048            }
     2049        }
     2050        $lifecycle = $this->get_historical_import_lifecycle_state();
     2051        update_option('statpl_history_import_state', sanitize_key((string) ($lifecycle['state'] ?? 'importing')), false);
     2052    }
     2053
    1332054    public function woocommerce_notice() {
    1342055        if (!current_user_can('manage_options') || class_exists('WooCommerce')) return;
    … …  
    1632084        $now = current_time('mysql');
    1642085        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is built from the WordPress prefix and a fixed plugin suffix; this atomic view-count write must not be cached.
    165         $wpdb->query($wpdb->prepare(
     2086        statpl_db_query(statpl_db_prepare(
    1662087            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Internal table identifier uses the WordPress prefix plus a fixed plugin suffix; product ID is prepared.
    1672088            "INSERT INTO {$this->views_table} (product_id, total_views) VALUES (%d, 1)
    … …  
    1692090        ));
    1702091        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Event writes must be persisted immediately and cannot be cached.
    171         $wpdb->insert($this->events_table, ['product_id' => $product_id, 'viewed_at' => $now], ['%d','%s']);
     2092        statpl_db_insert($this->events_table, ['product_id' => $product_id, 'viewed_at' => $now], ['%d','%s']);
    1722093        setcookie($cookie, '1', time() + 1200, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
    1732094        $_COOKIE[$cookie] = '1';
    … …  
    1752096        $cutoff = gmdate('Y-m-d H:i:s', time() - 172800);
    1762097        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is internal and the cutoff is prepared; this cleanup deletes expired rows, so caching is not applicable.
    177         $wpdb->query($wpdb->prepare("DELETE FROM {$this->events_table} WHERE viewed_at < %s", get_date_from_gmt($cutoff)));
     2098        statpl_db_query(statpl_db_prepare("DELETE FROM {$this->events_table} WHERE viewed_at < %s", get_date_from_gmt($cutoff)));
    1782099        wp_send_json_success(['counted' => true]);
    1792100    }
    … …  
    1872108        // This prevents a later snapshot sync from making an older cancellation appear to happen today.
    1882109        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; the query values are prepared.
    189         $event_at = $wpdb->get_var($wpdb->prepare(
     2110        $event_at = statpl_db_get_var(statpl_db_prepare(
    1902111            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; both query values are prepared.
    1912112            "SELECT MIN(event_at) FROM {$this->order_events_table} WHERE order_id = %d AND event_type = %s AND event_ref_id = 0",
    … …  
    2802201        // from being counted more than once in the store overview.
    2812202        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all event values are prepared.
    282         $wpdb->query($wpdb->prepare(
     2203        statpl_db_query(statpl_db_prepare(
    2832204            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; all event values are prepared.
    2842205            "INSERT INTO {$this->order_events_table} (order_id, product_id, event_type, event_ref_id, event_at) VALUES (%d, 0, %s, %d, %s) ON DUPLICATE KEY UPDATE event_at=LEAST(event_at, VALUES(event_at))",
    … …  
    2882209        foreach (array_keys($product_ids) as $product_id) {
    2892210            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all event values are prepared.
    290             $wpdb->query($wpdb->prepare(
     2211            statpl_db_query(statpl_db_prepare(
    2912212                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; all event values are prepared.
    2922213                "INSERT INTO {$this->order_events_table} (order_id, product_id, event_type, event_ref_id, event_at) VALUES (%d, %d, %s, %d, %s) ON DUPLICATE KEY UPDATE event_at=LEAST(event_at, VALUES(event_at))",
    … …  
    3092230        // Product ID 0 is reserved for order-level dashboard aggregation.
    3102231        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all event values are prepared.
    311         $wpdb->query($wpdb->prepare(
     2232        statpl_db_query(statpl_db_prepare(
    3122233            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; all event values are prepared.
    3132234            "INSERT INTO {$this->order_events_table} (order_id, product_id, event_type, event_ref_id, event_at) VALUES (%d, 0, %s, 0, %s) ON DUPLICATE KEY UPDATE event_at=LEAST(event_at, VALUES(event_at))",
    … …  
    3162237        foreach ($this->order_product_ids($order) as $product_id) {
    3172238            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all event values are prepared.
    318             $wpdb->query($wpdb->prepare(
     2239            statpl_db_query(statpl_db_prepare(
    3192240                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; all event values are prepared.
    3202241                "INSERT INTO {$this->order_events_table} (order_id, product_id, event_type, event_ref_id, event_at) VALUES (%d, %d, %s, 0, %s) ON DUPLICATE KEY UPDATE event_at=LEAST(event_at, VALUES(event_at))",
    … …  
    3252246        delete_transient('statpl_global_summary_v2');
    3262247    }
     2248    /** Add a two-choice confirmation dialog to this plugin's Plugins-screen action. */
     2249    public function enqueue_deactivation_prompt($hook_suffix) {
     2250        if ($hook_suffix !== 'plugins.php' || !current_user_can('activate_plugins')) {
     2251            return;
     2252        }
     2253        wp_enqueue_script('jquery-core');
     2254        $config = [
     2255            'plugin' => plugin_basename(__FILE__),
     2256            'endpoint' => admin_url('admin-post.php'),
     2257            'nonce' => wp_create_nonce('statpl_prepare_deactivation'),
     2258            'title' => 'Απενεργοποίηση Statora Plus',
     2259            'message' => 'Τι θέλεις να γίνει με τα στατιστικά και τα δεδομένα του Statora Plus;',
     2260            'retain' => 'Ναι, διατήρηση όλων των δεδομένων',
     2261            'delete' => 'Όχι, διαγραφή όλων των δεδομένων',
     2262            'cancel' => 'Ακύρωση',
     2263            'warning' => 'Η διαγραφή δεν μπορεί να αναιρεθεί.',
     2264        ];
     2265        wp_add_inline_script('jquery-core', 'window.StatoraDeactivatePrompt = ' . wp_json_encode($config) . ';', 'before');
     2266        $js = <<<'STATPL_DEACTIVATION_JS'
     2267(function () {
     2268    var cfg = window.StatoraDeactivatePrompt;
     2269    if (!cfg) return;
     2270    function findDeactivateLink(target) {
     2271        var link = target.closest('a');
     2272        if (!link) return null;
     2273        try {
     2274            var url = new URL(link.href, window.location.href);
     2275            if (url.searchParams.get('action') !== 'deactivate' || url.searchParams.get('plugin') !== cfg.plugin) return null;
     2276            return link;
     2277        } catch (e) { return null; }
     2278    }
     2279    document.addEventListener('click', function (event) {
     2280        var link = findDeactivateLink(event.target);
     2281        if (!link) return;
     2282        event.preventDefault();
     2283        event.stopImmediatePropagation();
     2284        if (document.getElementById('statpl-deactivation-dialog')) return;
     2285        var dialog = document.createElement('div');
     2286        dialog.id = 'statpl-deactivation-dialog';
     2287        dialog.style.cssText = 'position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;padding:20px';
     2288        var panel = document.createElement('div');
     2289        panel.style.cssText = 'background:#fff;max-width:520px;width:100%;padding:24px;border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,.25);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif';
     2290        var title = document.createElement('h2'); title.textContent = cfg.title; title.style.marginTop = '0';
     2291        var message = document.createElement('p'); message.textContent = cfg.message;
     2292        var warning = document.createElement('p'); warning.textContent = cfg.warning; warning.style.color = '#b32d2e';
     2293        var actions = document.createElement('div'); actions.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;margin-top:20px';
     2294        function button(label, choice, primary) {
     2295            var b = document.createElement('button'); b.type = 'button'; b.textContent = label;
     2296            b.className = primary ? 'button button-primary' : 'button';
     2297            b.addEventListener('click', function () {
     2298                if (!choice) { dialog.remove(); return; }
     2299                var destination = new URL(cfg.endpoint, window.location.href);
     2300                destination.searchParams.set('action', 'statpl_prepare_deactivation');
     2301                destination.searchParams.set('choice', choice);
     2302                destination.searchParams.set('_wpnonce', cfg.nonce);
     2303                destination.searchParams.set('redirect_to', link.href);
     2304                window.location.assign(destination.toString());
     2305            });
     2306            actions.appendChild(b);
     2307        }
     2308        button(cfg.retain, 'retain', true);
     2309        button(cfg.delete, 'delete', false);
     2310        button(cfg.cancel, '', false);
     2311        panel.appendChild(title); panel.appendChild(message); panel.appendChild(warning); panel.appendChild(actions);
     2312        dialog.appendChild(panel); document.body.appendChild(dialog);
     2313        dialog.addEventListener('click', function (e) { if (e.target === dialog) dialog.remove(); });
     2314    }, true);
     2315})();
     2316STATPL_DEACTIVATION_JS;
     2317        wp_add_inline_script('jquery-core', $js, 'after');
     2318    }
     2319
    3272320    public function enqueue_admin_styles($hook_suffix) {
    3282321        if ('toplevel_page_statpl-product-metrics' !== $hook_suffix) {
    … …  
    3332326        $css = <<<'STATPL_CSS'
    3342327        .statpl-table-scroll{overflow-x:auto}
    335         .statpl-stock-not-tracked{color:#646970}
     2328        .statpl-stock-not-tracked{color:#646970}.statpl-product-visibility{display:inline-block;margin-left:7px;color:#646970;font-size:12px;font-weight:400}
    3362329        .statpl-metrics-wrap .nav-tab{border-color:#c9d7ff;background:#f2f6ff;color:#3155F5;font-weight:600}
    3372330        .statpl-metrics-wrap .nav-tab-active{background:#fff;color:#1d2327;border-bottom-color:#fff;border-top:3px solid #3155F5}
    … …  
    3512344        .statpl-metrics-wrap .notice-success.inline p{color:#263b8f}
    3522345        .statpl-metrics-wrap .statpl-overview-note{color:#505b6b}
     2346        .statpl-history-notice{position:relative;box-sizing:border-box;margin:15px 0;padding:14px 42px 14px 14px;border:1px solid #c5e2a9;border-radius:6px;background:#DDF2C8;color:#000}.statpl-history-notice-importing{background:#D9EEFF;border-color:#b8dcf5}.statpl-history-notice-icon{color:#2271b1;margin-right:6px;vertical-align:middle}.statpl-history-notice p{margin:8px 0;color:#000}.statpl-history-notice strong{color:#000}.statpl-history-notice-close{position:absolute;top:5px;right:8px;padding:0 5px;border:0;background:transparent;color:#44515e;font-size:22px;line-height:1.2;cursor:pointer}.statpl-history-report-link{display:inline-block;margin-top:3px;color:#0073aa;text-decoration:none}.statpl-history-report-link:hover{text-decoration:underline}
    3532347        .statpl-metrics-wrap .statpl-metrics-toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap;margin:18px 0 12px}.statpl-metrics-wrap .statpl-sort-form{display:flex;align-items:center;gap:8px}.statpl-metrics-wrap .statpl-metrics-search{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin:0}.statpl-metrics-wrap .statpl-metrics-search input[type=search]{width:min(375px,100%)}.statpl-metrics-wrap .statpl-metrics-search input[type=submit],.statpl-metrics-wrap .button-primary{background:#3155F5!important;border-color:#3155F5!important;color:#fff!important}
    3542348        .statpl-metrics-wrap .widefat{border-color:#c9d7ff;border-collapse:separate;border-spacing:0}
    … …  
    3662360
    3672361    public function admin_menu() {
     2362        // The detailed import report is intentionally not a permanent menu item.
     2363        // It is reachable only through the temporary notice link for partial or
     2364        // technical outcomes, and only during the 15-minute access window.
    3682365        add_menu_page('Statora Plus', 'Statora Plus', 'manage_woocommerce', 'statpl-product-metrics', [$this, 'render_page'], 'dashicons-chart-bar', 56);
     2366    }
     2367
     2368    /** Add the silent 20-second dismissal behavior; manual close cancels the timer. */
     2369    public function enqueue_historical_notice_script() {
     2370        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a read-only admin-screen routing check; it does not process form data or perform a state-changing action. Mutating admin actions use their own nonce verification.
     2371        if (!isset($_GET['page']) || !in_array(sanitize_key(wp_unslash($_GET['page'])), ['statpl-product-metrics','statpl-history-report'], true)) { return; }
     2372        echo '<script>(function(){var n=document.querySelector(".statpl-history-notice");if(!n)return;var t=window.setTimeout(function(){if(n.parentNode)n.parentNode.removeChild(n);},20000);var b=n.querySelector(".statpl-history-notice-close");if(b)b.addEventListener("click",function(){window.clearTimeout(t);if(n.parentNode)n.parentNode.removeChild(n);});})();</script>';
    3692373    }
    3702374    /** Bulk-load view counts for a product set to avoid two or more queries per product. */
    … …  
    3762380        return $this->today_start_datetime()->format('Y-m-d H:i:s');
    3772381    }
    378     /** Return a displayable stock quantity without double-counting parent and variation stock. */
     2382    /**
     2383     * Return stock quantity and WooCommerce availability without double-counting
     2384     * parent-managed and variation-managed inventory.
     2385     */
    3792386    private function stock_summary_for_product($product) {
    380         if (!$product) return ['managed' => false, 'quantity' => null];
     2387        if (!$product) {
     2388            return ['managed' => false, 'quantity' => null, 'in_stock' => false];
     2389        }
     2390
     2391        // WooCommerce's native availability API treats both instock and
     2392        // onbackorder as purchasable, and respects its stock-status filters.
     2393        $in_stock = (bool) $product->is_in_stock();
     2394
     2395        // When the parent manages stock, its quantity is authoritative.
     2396        // Do not add variation quantities to it.
    3812397        if ($product->managing_stock()) {
    3822398            $quantity = $product->get_stock_quantity();
    383             return ['managed' => true, 'quantity' => is_numeric($quantity) ? (float) $quantity : 0.0];
    384         }
     2399            return [
     2400                'managed' => true,
     2401                'quantity' => is_numeric($quantity) ? (float) $quantity : 0.0,
     2402                'in_stock' => $in_stock,
     2403            ];
     2404        }
     2405
     2406        // Otherwise, sum only variations that explicitly manage their own stock.
    3852407        if ($product->is_type('variable')) {
    3862408            $managed_any = false;
    … …  
    3882410            foreach ($product->get_children() as $variation_id) {
    3892411                $variation = wc_get_product($variation_id);
    390                 if (!$variation || !$variation->managing_stock()) continue;
     2412                if (!$variation || !$variation->managing_stock()) {
     2413                    continue;
     2414                }
    3912415                $managed_any = true;
    3922416                $quantity = $variation->get_stock_quantity();
    393                 if (is_numeric($quantity)) $total += (float) $quantity;
    394             }
    395             if ($managed_any) return ['managed' => true, 'quantity' => $total];
    396         }
    397         return ['managed' => false, 'quantity' => null];
     2417                if (is_numeric($quantity)) {
     2418                    $total += (float) $quantity;
     2419                }
     2420            }
     2421            if ($managed_any) {
     2422                return ['managed' => true, 'quantity' => $total, 'in_stock' => $in_stock];
     2423            }
     2424        }
     2425
     2426        return ['managed' => false, 'quantity' => null, 'in_stock' => $in_stock];
    3982427    }
    3992428
    … …  
    4112440        $placeholders = implode(',', array_fill(0, count($ids), '%d'));
    4122441        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all query values are prepared.
    413         $totals = $wpdb->get_results(
    414             $wpdb->prepare(
     2442        $totals = statpl_db_get_results(
     2443            statpl_db_prepare(
    4152444                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated; the IN placeholders are generated from the count of integer IDs, and every ID is passed to prepare() as a value.
    4162445                "SELECT product_id, total_views FROM {$this->views_table} WHERE product_id IN ($placeholders)",
    … …  
    4282457        $since = $this->today_start_mysql();
    4292458        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all query values are prepared.
    430         $recent = $wpdb->get_results(
    431             $wpdb->prepare(
     2459        $recent = statpl_db_get_results(
     2460            statpl_db_prepare(
    4322461                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated; the date and every product ID are passed to prepare() as values. The IN placeholders are generated from the count of integer IDs.
    4332462                "SELECT product_id, COUNT(*) AS views_today FROM {$this->events_table} WHERE viewed_at >= %s AND product_id IN ($placeholders) GROUP BY product_id",
    … …  
    4712500        // Replace a snapshot atomically so readers never see a half-written order.
    4722501        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control is required for an atomic snapshot replacement and cannot be cached.
    473         if ($wpdb->query('START TRANSACTION') === false) return false;
     2502        if (statpl_db_query('START TRANSACTION') === false) return false;
    4742503        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This write replaces the stored order snapshot inside a transaction; it must execute immediately and must not be cached.
    475         $deleted = $wpdb->delete($this->order_items_table, ['order_id' => $order_id], ['%d']);
     2504        $deleted = statpl_db_delete($this->order_items_table, ['order_id' => $order_id], ['%d']);
    4762505        if ($deleted === false) {
    4772506            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction rollback is required after a failed write; caching is not applicable.
    478             $wpdb->query('ROLLBACK');
     2507            statpl_db_query('ROLLBACK');
    4792508            return false;
    4802509        }
    … …  
    5392568        foreach ($by_product as $pid => $row) {
    5402569            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- These rows are the authoritative refreshed order snapshot; writes must execute immediately and must not be cached.
    541             $inserted = $wpdb->insert($this->order_items_table, [
     2570            $inserted = statpl_db_insert($this->order_items_table, [
    5422571                'order_id'=>(int)$order_id, 'product_id'=>(int)$pid, 'order_status'=>$order->get_status(),
    5432572                'created_at'=>$created_at, 'paid_at'=>$paid_at, 'completed_at'=>$completed_at,
    … …  
    5472576            if ($inserted === false) {
    5482577                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction rollback is required after a failed write; caching is not applicable.
    549                 $wpdb->query('ROLLBACK');
     2578                statpl_db_query('ROLLBACK');
    5502579                return false;
    5512580            }
    5522581        }
    5532582        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction commit is required to persist the completed snapshot atomically.
    554         if ($wpdb->query('COMMIT') === false) {
    555             $wpdb->query('ROLLBACK');
     2583        if (statpl_db_query('COMMIT') === false) {
     2584            statpl_db_query('ROLLBACK');
    5562585            return false;
    5572586        }
    … …  
    5742603        global $wpdb;
    5752604        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Deleting a removed order snapshot is a required write; caching is not applicable.
    576         $wpdb->delete($this->order_items_table, ['order_id'=>absint($order_id)], ['%d']);
     2605        statpl_db_delete($this->order_items_table, ['order_id'=>absint($order_id)], ['%d']);
    5772606        delete_transient('statpl_order_stats_v1');
    5782607        delete_transient('statpl_global_summary_v2');
    5792608    }
     2609    /**
     2610     * Discover source-specific adapters registered by compatible analytics plugins.
     2611     * This registry is intentionally explicit: Statora Plus does not guess the
     2612     * meaning of arbitrary third-party database tables.
     2613     */
     2614    public function discover_historical_view_adapters() {
     2615        global $wpdb;
     2616
     2617        // Discovery mutates the persisted source/origin inventory and schedules
     2618        // snapshot/import work. Serialize it across concurrent WordPress requests
     2619        // so two provider scans cannot race while reconciling the same origins.
     2620        // Acquire the lock before clearing the request-local registry: a request
     2621        // that cannot enter discovery must not discard a still-valid registry.
     2622        $lock_name = 'statpl_generic_adapter_discovery';
     2623        $discovery_lock = $this->acquire_generic_advisory_lock($lock_name);
     2624        if (empty($discovery_lock['acquired'])) {
     2625            $status = isset($discovery_lock['status']) ? $discovery_lock['status'] : 'lock_unavailable';
     2626            if ($status !== 'lock_busy_or_unavailable') {
     2627                $this->log_generic_historical_scheduler_error(
     2628                    'adapter_discovery_lock',
     2629                    isset($discovery_lock['error']) ? $discovery_lock['error'] : $status
     2630                );
     2631            }
     2632            return ['status' => $status === 'lock_busy_or_unavailable' ? 'busy' : 'lock_unavailable', 'registered' => 0, 'rejected' => 0];
     2633        }
     2634
     2635        try {
     2636            if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2637                return ['status' => 'lock_session_lost', 'registered' => 0, 'rejected' => 0];
     2638            }
     2639            return $this->discover_historical_view_adapters_locked($discovery_lock);
     2640        } finally {
     2641            // Never attempt to release a connection-scoped lock through a replacement session.
     2642            $this->release_generic_advisory_lock($discovery_lock, 'adapter_discovery');
     2643        }
     2644    }
     2645
     2646    /** Perform provider discovery while holding the global discovery lock. */
     2647    private function discover_historical_view_adapters_locked($discovery_lock) {
     2648        // Invalidate request-local adapter objects before asking providers to
     2649        // rebuild the registry. If a provider throws, retaining objects from an
     2650        // earlier discovery pass could execute an adapter whose plugin is no
     2651        // longer available or whose registration is now failing. Persisted
     2652        // adapter identity/snapshot/checkpoint remain untouched in the database.
     2653        $this->historical_view_adapters = [];
     2654
     2655        // A third-party provider callback can throw while the registry filter is
     2656        // being assembled. Contain that failure here so it cannot interrupt
     2657        // Statora Plus initialization or the independent live-view tracker.
     2658        try {
     2659            $provided = apply_filters('statpl_historical_view_adapters', []);
     2660        } catch (Throwable $exception) {
     2661            if (defined('WP_DEBUG') && WP_DEBUG) {
     2662                $this->debug_log('Statora Plus: historical adapter registry filter failed: ' . $exception->getMessage());
     2663            }
     2664            return ['status' => 'provider_error', 'registered' => 0, 'rejected' => 1];
     2665        }
     2666        if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2667            return ['status' => 'lock_session_lost', 'registered' => 0, 'rejected' => 0];
     2668        }
     2669        if (!is_array($provided)) {
     2670            return ['status' => 'invalid_registry', 'registered' => 0, 'rejected' => 0];
     2671        }
     2672
     2673        // A successful discovery pass is authoritative; the registry was
     2674        // cleared before invoking third-party provider callbacks above.
     2675
     2676        $registered = 0;
     2677        $rejected = 0;
     2678        $seen_adapter_keys = [];
     2679        // A source_key is a persistent namespace, so two different adapter
     2680        // identities must not claim it during the same discovery pass—even if
     2681        // the first adapter currently reports no origins and therefore has no
     2682        // origin row from which the database conflict check could detect it.
     2683        $seen_source_keys = [];
     2684
     2685        foreach ($provided as $adapter) {
     2686            if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2687                return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected];
     2688            }
     2689            if (!is_object($adapter) || !$this->is_valid_historical_view_adapter($adapter)) {
     2690                $rejected++;
     2691                continue;
     2692            }
     2693
     2694            try {
     2695                $adapter_key = sanitize_key((string) $adapter->get_adapter_key());
     2696                $source_key = sanitize_key((string) $adapter->get_source_key());
     2697                $display_name = sanitize_text_field((string) $adapter->get_display_name());
     2698                $adapter_version = sanitize_text_field((string) $adapter->get_adapter_version());
     2699
     2700                // Adapter metadata methods are third-party callbacks. They may
     2701                // perform database work or trigger a reconnect; never continue
     2702                // discovery writes under an assumed connection after invoking them.
     2703                if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2704                    return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected];
     2705                }
     2706
     2707                if (
     2708                    $adapter_key === ''
     2709                    || $source_key === ''
     2710                    || $display_name === ''
     2711                    || $adapter_version === ''
     2712                    || strlen($adapter_key) > 100
     2713                    || strlen($source_key) > 100
     2714                    || strlen($display_name) > 191
     2715                    || strlen($adapter_version) > 32
     2716                    || isset($seen_adapter_keys[$adapter_key])
     2717                ) {
     2718                    $rejected++;
     2719                    continue;
     2720                }
     2721
     2722                // SlimStat already has a mature, separately tested import pipeline.
     2723                // It must not be registered a second time through this generic path.
     2724                if ($source_key === 'slimstat' || $adapter_key === 'slimstat') {
     2725                    continue;
     2726                }
     2727
     2728                if (isset($seen_source_keys[$source_key]) && $seen_source_keys[$source_key] !== $adapter_key) {
     2729                    $rejected++;
     2730                    if (defined('WP_DEBUG') && WP_DEBUG) {
     2731                        $this->debug_log('Statora Plus: rejected adapter "' . $adapter_key . '" because source_key "' . $source_key . '" was already claimed by another adapter in this discovery pass.');
     2732                    }
     2733                    continue;
     2734                }
     2735                $seen_source_keys[$source_key] = $adapter_key;
     2736
     2737                // A source_key is the persistent namespace for all of its origins.
     2738                // Do not allow a different adapter identity to append a second set
     2739                // of origins to an existing source, even when the origin_key values
     2740                // do not overlap. The discovery advisory lock makes this check and
     2741                // the subsequent registration one serialized discovery operation.
     2742                global $wpdb;
     2743                $conflicting_adapter_key = statpl_db_get_var(statpl_db_prepare(
     2744                    "SELECT origins.adapter_key
     2745                     FROM {$this->view_source_origins_table} AS origins
     2746                     INNER JOIN {$this->view_sources_table} AS sources
     2747                       ON sources.id = origins.source_id
     2748                     WHERE sources.source_key = %s
     2749                       AND (sources.adapter_key <> %s OR origins.adapter_key <> %s)
     2750                     LIMIT 1",
     2751                    $source_key,
     2752                    $adapter_key,
     2753                    $adapter_key
     2754                ));
     2755                if (!empty($wpdb->last_error)) {
     2756                    $rejected++;
     2757                    $this->log_generic_historical_scheduler_error(
     2758                        'adapter_identity_lookup',
     2759                        $wpdb->last_error
     2760                    );
     2761                    continue;
     2762                }
     2763                if ($conflicting_adapter_key !== null && $conflicting_adapter_key !== '') {
     2764                    $rejected++;
     2765                    if (defined('WP_DEBUG') && WP_DEBUG) {
     2766                        $this->debug_log('Statora Plus: rejected adapter "' . $adapter_key . '" because source_key "' . $source_key . '" is already bound to another adapter identity.');
     2767                    }
     2768                    continue;
     2769                }
     2770
     2771                $origins = $adapter->discover_origins();
     2772                if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2773                    return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected];
     2774                }
     2775                if (!is_array($origins)) {
     2776                    $rejected++;
     2777                    continue;
     2778                }
     2779
     2780                $valid_origins = [];
     2781                $seen_origin_keys = [];
     2782                $origin_declaration_invalid = false;
     2783                foreach ($origins as $origin) {
     2784                    if (!is_array($origin)) {
     2785                        $rejected++;
     2786                        $origin_declaration_invalid = true;
     2787                        continue;
     2788                    }
     2789
     2790                    $origin_key = isset($origin['origin_key'])
     2791                        ? sanitize_key((string) $origin['origin_key'])
     2792                        : '';
     2793                    $capabilities = isset($origin['capabilities']) && is_array($origin['capabilities'])
     2794                        ? $origin['capabilities']
     2795                        : [];
     2796                    $detection_status = isset($origin['detection_status'])
     2797                        ? (string) $origin['detection_status']
     2798                        : 'unknown';
     2799
     2800                    // Only explicit product-view capability qualifies as a source.
     2801                    if (
     2802                        $origin_key === ''
     2803                        || strlen($origin_key) > 100
     2804                        || isset($seen_origin_keys[$origin_key])
     2805                        || empty($capabilities['product_views'])
     2806                        || !in_array($detection_status, ['unknown', 'available', 'missing_table', 'incompatible_schema', 'database_error'], true)
     2807                    ) {
     2808                        $rejected++;
     2809                        $origin_declaration_invalid = true;
     2810                        continue;
     2811                    }
     2812
     2813                    // Sanitization can collapse distinct provider values to the
     2814                    // same key. Register each origin only once per discovery pass
     2815                    // so duplicate declarations cannot overwrite capabilities or
     2816                    // schedule duplicate snapshot/import initialization work.
     2817                    $seen_origin_keys[$origin_key] = true;
     2818                    $valid_origins[] = [
     2819                        'origin_key' => $origin_key,
     2820                        'capabilities' => $capabilities,
     2821                        'detection_status' => $detection_status,
     2822                    ];
     2823                }
     2824
     2825                if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2826                    return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected];
     2827                }
     2828                $source_result = $this->register_view_source(
     2829                    $source_key,
     2830                    $display_name,
     2831                    $adapter_version,
     2832                    'event',
     2833                    'detected',
     2834                    $adapter_key
     2835                );
     2836
     2837                if (empty($source_result['source_id'])) {
     2838                    $rejected++;
     2839                    continue;
     2840                }
     2841
     2842                // A successful discovery response is authoritative for the
     2843                // adapter's current origin inventory. If the provider returns a
     2844                // temporarily incomplete (or empty) list, preserve omitted
     2845                // origins and their cursors but take them out of active work.
     2846                // Do not infer omissions from a malformed/partially rejected
     2847                // declaration list, because that is not a trustworthy inventory.
     2848                $seen_adapter_keys[$adapter_key] = true;
     2849                if (!$origin_declaration_invalid) {
     2850                    $missing_origins = $this->mark_missing_generic_historical_origins_unavailable(
     2851                        $source_result['source_id'],
     2852                        $adapter_key,
     2853                        array_column($valid_origins, 'origin_key'),
     2854                        $discovery_lock
     2855                    );
     2856                    if (empty($missing_origins['status']) || $missing_origins['status'] !== 'updated') {
     2857                        // Do not register or schedule this adapter's newly reported
     2858                        // origins against a partially reconciled inventory. Keep the
     2859                        // previous persisted inventory intact and retry on the next
     2860                        // discovery pass after the database is healthy.
     2861                        $rejected++;
     2862                        $this->log_generic_historical_scheduler_error(
     2863                            'origin_inventory_reconciliation',
     2864                            isset($missing_origins['error']) ? $missing_origins['error'] : 'Origin inventory reconciliation did not commit.'
     2865                        );
     2866                        continue;
     2867                    }
     2868                }
     2869
     2870                if (!$valid_origins) {
     2871                    if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2872                        return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected];
     2873                    }
     2874                    $this->refresh_generic_historical_source_status($source_result['source_id']);
     2875                    continue;
     2876                }
     2877
     2878                $registered_origins = [];
     2879                $origin_registration_failures = 0;
     2880                $adapter_mismatch_detected = false;
     2881                foreach ($valid_origins as $origin) {
     2882                    if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2883                        return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected];
     2884                    }
     2885                    // Isolate each origin completely. A provider/database exception
     2886                    // while registering one origin must not prevent sibling origins
     2887                    // from being registered and scheduled in this discovery pass.
     2888                    try {
     2889                        $origin_result = $this->register_view_source_origin(
     2890                            $source_result['source_id'],
     2891                            $origin['origin_key'],
     2892                            $adapter_key,
     2893                            $origin['capabilities'],
     2894                            $origin['detection_status']
     2895                        );
     2896                    } catch (Throwable $exception) {
     2897                        $origin_result = [
     2898                            'status' => 'origin_exception',
     2899                            'error' => $exception->getMessage(),
     2900                        ];
     2901                        if (defined('WP_DEBUG') && WP_DEBUG) {
     2902                            $this->debug_log('Statora Plus: origin registration failed for "' . $origin['origin_key'] . '": ' . $exception->getMessage());
     2903                        }
     2904                    }
     2905
     2906                    if (!empty($origin_result['status']) && $origin_result['status'] === 'registered') {
     2907                        $registered_origins[] = $origin;
     2908                    } elseif (!empty($origin_result['status']) && $origin_result['status'] === 'adapter_mismatch') {
     2909                        $adapter_mismatch_detected = true;
     2910                        $origin_registration_failures++;
     2911                    } else {
     2912                        $origin_registration_failures++;
     2913                    }
     2914                }
     2915                if ($adapter_mismatch_detected) {
     2916                    // Preserve the established identity and cursor of the mismatched
     2917                    // origin, but do not let that isolated conflict block sibling
     2918                    // origins that were registered successfully with this adapter.
     2919                    $this->refresh_generic_historical_source_status($source_result['source_id']);
     2920                }
     2921
     2922                if ($registered_origins) {
     2923                    $this->historical_view_adapters[$adapter_key] = $adapter;
     2924                    $snapshot_result = $this->initialize_generic_historical_view_snapshots(
     2925                        $source_result['source_id'],
     2926                        $adapter_key,
     2927                        $registered_origins,
     2928                        $discovery_lock
     2929                    );
     2930                    if (($snapshot_result['status'] ?? '') === 'lock_session_lost'
     2931                        || !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2932                        return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected];
     2933                    }
     2934                    $this->refresh_generic_historical_source_status($source_result['source_id']);
     2935                    $registered++;
     2936                    if (!empty($snapshot_result['failed']) || $origin_registration_failures > 0) {
     2937                        $rejected++;
     2938                    }
     2939                    foreach ($registered_origins as $origin) {
     2940                        if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2941                            return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected];
     2942                        }
     2943                        if ($origin['detection_status'] === 'available') {
     2944                            $this->schedule_generic_historical_view_import_batch(15, $source_result['source_id'], $origin['origin_key']);
     2945                        }
     2946                    }
     2947                } else {
     2948                    $rejected++;
     2949                }
     2950            } catch (Throwable $exception) {
     2951                $rejected++;
     2952                if (defined('WP_DEBUG') && WP_DEBUG) {
     2953                    $this->debug_log('Statora Plus: historical adapter discovery failed: ' . $exception->getMessage());
     2954                }
     2955            }
     2956        }
     2957
     2958        return [
     2959            'status' => 'completed',
     2960            'registered' => $registered,
     2961            'rejected' => $rejected,
     2962        ];
     2963    }
     2964
     2965    /** Persist snapshot and its initial checkpoint together, exactly once per origin. */
     2966    private function save_generic_historical_snapshot_if_empty($source_id, $origin_key, $snapshot, $checkpoint) {
     2967        global $wpdb;
     2968
     2969        $source_id = absint($source_id);
     2970        $origin_key = is_string($origin_key) ? trim($origin_key) : '';
     2971        if (!$source_id || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key)
     2972            || !is_array($snapshot) || empty($snapshot) || !is_array($checkpoint)) {
     2973            return ['status' => 'invalid_input'];
     2974        }
     2975
     2976        $snapshot_json = wp_json_encode($snapshot);
     2977        $checkpoint_json = wp_json_encode($checkpoint);
     2978        if (!is_string($snapshot_json) || !is_string($checkpoint_json)) {
     2979            return ['status' => 'encoding_error'];
     2980        }
     2981
     2982        $result = statpl_db_query(statpl_db_prepare(
     2983            "UPDATE {$this->view_source_origins_table}
     2984             SET snapshot_data = %s, checkpoint_data = %s, status = %s,
     2985                 started_at = %s, last_error = NULL, updated_at = %s
     2986             WHERE source_id = %d AND origin_key = %s
     2987               AND snapshot_data IS NULL AND checkpoint_data IS NULL",
     2988            $snapshot_json,
     2989            $checkpoint_json,
     2990            'snapshot_ready',
     2991            current_time('mysql'),
     2992            current_time('mysql'),
     2993            $source_id,
     2994            $origin_key
     2995        ));
     2996
     2997        if ($result === false || $wpdb->last_error !== '') {
     2998            return ['status' => 'database_error', 'error' => $wpdb->last_error];
     2999        }
     3000        if ($result === 1) {
     3001            return ['status' => 'updated', 'source_id' => $source_id, 'origin_key' => $origin_key];
     3002        }
     3003
     3004        $existing = $this->get_view_source_origin_state($source_id, $origin_key);
     3005        if ($existing['status'] !== 'found') {
     3006            return $existing;
     3007        }
     3008        if (is_array($existing['snapshot']) && !empty($existing['snapshot'])
     3009            && is_array($existing['checkpoint'])) {
     3010            return [
     3011                'status' => 'already_initialized',
     3012                'source_id' => $source_id,
     3013                'origin_key' => $origin_key,
     3014                'snapshot' => $existing['snapshot'],
     3015                'checkpoint' => $existing['checkpoint'],
     3016            ];
     3017        }
     3018        return ['status' => 'initialization_conflict', 'source_id' => $source_id, 'origin_key' => $origin_key];
     3019    }
     3020
     3021    /**
     3022     * Capture an immutable upper-bound snapshot for every detected generic origin.
     3023     * Existing snapshots are never overwritten, so retries/re-discovery cannot move
     3024     * the historical import boundary forward and accidentally include live traffic.
     3025     */
     3026    private function initialize_generic_historical_view_snapshots($source_id, $adapter_key, $origins, $discovery_lock = null) {
     3027        $adapter = $this->get_historical_view_adapter($adapter_key);
     3028        if (!$adapter) {
     3029            return ['status' => 'adapter_unavailable', 'failed' => 1];
     3030        }
     3031
     3032        $initialized = 0;
     3033        $skipped = 0;
     3034        $failed = 0;
     3035
     3036        foreach ($origins as $origin) {
     3037            if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3038                return [
     3039                    'status' => 'lock_session_lost',
     3040                    'initialized' => $initialized,
     3041                    'skipped' => $skipped,
     3042                    'failed' => $failed,
     3043                ];
     3044            }
     3045            if (!is_array($origin) || empty($origin['origin_key'])) {
     3046                $failed++;
     3047                continue;
     3048            }
     3049            if (($origin['detection_status'] ?? '') !== 'available') {
     3050                $skipped++;
     3051                continue;
     3052            }
     3053
     3054            $state = $this->get_view_source_origin_state($source_id, $origin['origin_key']);
     3055            if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3056                return [
     3057                    'status' => 'lock_session_lost',
     3058                    'initialized' => $initialized,
     3059                    'skipped' => $skipped,
     3060                    'failed' => $failed,
     3061                ];
     3062            }
     3063            if (empty($state['status']) || $state['status'] !== 'found') {
     3064                $failed++;
     3065                $failure = $this->record_generic_historical_view_failure($source_id,
     3066                    $origin['origin_key'],
     3067                    'snapshot_initialization',
     3068                    0,
     3069                    'Could not read the stored origin state while initializing its historical snapshot.',
     3070                    null,
     3071                    8,
     3072                    $discovery_lock);
     3073                if (!$failure['blocked']) {
     3074                    $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']);
     3075                }
     3076                continue;
     3077            }
     3078            if (is_array($state['snapshot']) && !empty($state['snapshot'])) {
     3079                // A persisted snapshot is already valid; clear any stale
     3080                // initialization failure only while this discovery worker still
     3081                // owns the lock that serializes snapshot-ledger changes.
     3082                if (!$this->clear_generic_historical_view_failure($source_id, $origin['origin_key'], 'snapshot_initialization', $discovery_lock)) {
     3083                    $failed++;
     3084                    $this->schedule_generic_historical_snapshot_retry(60, $source_id, $origin['origin_key']);
     3085                    continue;
     3086                }
     3087                $skipped++;
     3088                continue;
     3089            }
     3090
     3091            try {
     3092                $snapshot = $adapter->get_origin_snapshot($origin['origin_key']);
     3093                if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3094                    return [
     3095                        'status' => 'lock_session_lost',
     3096                        'initialized' => $initialized,
     3097                        'skipped' => $skipped,
     3098                        'failed' => $failed,
     3099                    ];
     3100                }
     3101                if (!is_array($snapshot) || empty($snapshot)) {
     3102                    $failed++;
     3103                    if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3104                        return [
     3105                            'status' => 'lock_session_lost',
     3106                            'initialized' => $initialized,
     3107                            'skipped' => $skipped,
     3108                            'failed' => $failed,
     3109                        ];
     3110                    }
     3111                    $failure = $this->record_generic_historical_view_failure($source_id,
     3112                    $origin['origin_key'],
     3113                    'snapshot_initialization',
     3114                    0,
     3115                    'Adapter returned an empty or invalid historical snapshot.',
     3116                    null,
     3117                    8,
     3118                    $discovery_lock);
     3119                    if (!$failure['blocked']) {
     3120                        $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']);
     3121                    }
     3122                    continue;
     3123                }
     3124
     3125                // Adapter snapshots must declare their own stable boundary. The core
     3126                // records the capture time but does not guess source-specific cursors.
     3127                if (!isset($snapshot['captured_at'])) {
     3128                    $snapshot['captured_at'] = current_time('mysql');
     3129                }
     3130                if (!isset($snapshot['boundary']) || !is_array($snapshot['boundary']) || empty($snapshot['boundary'])) {
     3131                    $failed++;
     3132                    if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3133                        return [
     3134                            'status' => 'lock_session_lost',
     3135                            'initialized' => $initialized,
     3136                            'skipped' => $skipped,
     3137                            'failed' => $failed,
     3138                        ];
     3139                    }
     3140                    $failure = $this->record_generic_historical_view_failure($source_id,
     3141                    $origin['origin_key'],
     3142                    'snapshot_initialization',
     3143                    0,
     3144                    'Adapter snapshot did not provide the required immutable boundary descriptor.',
     3145                    null,
     3146                    8,
     3147                    $discovery_lock);
     3148                    if (!$failure['blocked']) {
     3149                        $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']);
     3150                    }
     3151                    continue;
     3152                }
     3153
     3154                $initial_checkpoint = array_key_exists('initial_checkpoint', $snapshot)
     3155                    ? $snapshot['initial_checkpoint']
     3156                    : null;
     3157                // A null initial cursor is valid (many sources begin at the start
     3158                // of their history). Non-null cursors must be JSON-safe scalar/array
     3159                // values within the same storage limit used for subsequent cursors.
     3160                if ($initial_checkpoint !== null && !$this->is_valid_generic_historical_cursor($initial_checkpoint)) {
     3161                    $failed++;
     3162                    if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3163                        return [
     3164                            'status' => 'lock_session_lost',
     3165                            'initialized' => $initialized,
     3166                            'skipped' => $skipped,
     3167                            'failed' => $failed,
     3168                        ];
     3169                    }
     3170                    $failure = $this->record_generic_historical_view_failure($source_id,
     3171                    $origin['origin_key'],
     3172                    'snapshot_initialization',
     3173                    0,
     3174                    'Adapter snapshot contained an invalid initial checkpoint. Expected null or a JSON-safe scalar/array cursor no larger than 60 KB.',
     3175                    null,
     3176                    8,
     3177                    $discovery_lock);
     3178                    if (!$failure['blocked']) {
     3179                        $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']);
     3180                    }
     3181                    continue;
     3182                }
     3183
     3184                $checkpoint = [
     3185                    'cursor' => $initial_checkpoint,
     3186                    'processed' => 0,
     3187                    'snapshot_captured_at' => $snapshot['captured_at'],
     3188                ];
     3189                unset($snapshot['initial_checkpoint']);
     3190
     3191                if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3192                    return [
     3193                        'status' => 'lock_session_lost',
     3194                        'initialized' => $initialized,
     3195                        'skipped' => $skipped,
     3196                        'failed' => $failed,
     3197                    ];
     3198                }
     3199                $saved = $this->save_generic_historical_snapshot_if_empty(
     3200                    $source_id,
     3201                    $origin['origin_key'],
     3202                    $snapshot,
     3203                    $checkpoint
     3204                );
     3205                if (empty($saved['status']) || !in_array($saved['status'], ['updated', 'already_initialized'], true)) {
     3206                    $failed++;
     3207                    if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3208                        return [
     3209                            'status' => 'lock_session_lost',
     3210                            'initialized' => $initialized,
     3211                            'skipped' => $skipped,
     3212                            'failed' => $failed,
     3213                        ];
     3214                    }
     3215                    $failure = $this->record_generic_historical_view_failure($source_id,
     3216                    $origin['origin_key'],
     3217                    'snapshot_initialization',
     3218                    0,
     3219                    'Could not persist the immutable snapshot and initial checkpoint: ' . (isset($saved['status']) ? $saved['status'] : 'unknown error'),
     3220                    null,
     3221                    8,
     3222                    $discovery_lock);
     3223                    if (!$failure['blocked']) {
     3224                        $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']);
     3225                    }
     3226                    continue;
     3227                }
     3228                if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3229                    return [
     3230                        'status' => 'lock_session_lost',
     3231                        'initialized' => $initialized,
     3232                        'skipped' => $skipped,
     3233                        'failed' => $failed,
     3234                    ];
     3235                }
     3236                $failure_cleared = $this->clear_generic_historical_view_failure($source_id, $origin['origin_key'], 'snapshot_initialization', $discovery_lock);
     3237                if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3238                    return [
     3239                        'status' => 'lock_session_lost',
     3240                        'initialized' => $initialized,
     3241                        'skipped' => $skipped,
     3242                        'failed' => $failed,
     3243                    ];
     3244                }
     3245                if (!$failure_cleared) {
     3246                    $failed++;
     3247                    $this->schedule_generic_historical_snapshot_retry(60, $source_id, $origin['origin_key']);
     3248                    continue;
     3249                }
     3250                if ($saved['status'] === 'updated') {
     3251                    $initialized++;
     3252                } else {
     3253                    $skipped++;
     3254                }
     3255            } catch (Throwable $exception) {
     3256                if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3257                    return [
     3258                        'status' => 'lock_session_lost',
     3259                        'initialized' => $initialized,
     3260                        'skipped' => $skipped,
     3261                        'failed' => $failed,
     3262                    ];
     3263                }
     3264                $failed++;
     3265                $failure = $this->record_generic_historical_view_failure($source_id,
     3266                    $origin['origin_key'],
     3267                    'snapshot_initialization',
     3268                    0,
     3269                    $exception->getMessage(),
     3270                    null,
     3271                    8,
     3272                    $discovery_lock);
     3273                if (!$failure['blocked']) {
     3274                    $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']);
     3275                }
     3276                if (defined('WP_DEBUG') && WP_DEBUG) {
     3277                    $this->debug_log('Statora Plus: historical origin snapshot failed: ' . $exception->getMessage());
     3278                }
     3279            }
     3280        }
     3281
     3282        return [
     3283            'status' => $failed ? 'partial' : 'completed',
     3284            'initialized' => $initialized,
     3285            'skipped' => $skipped,
     3286            'failed' => $failed,
     3287        ];
     3288    }
     3289
     3290    /**
     3291     * Preserve origins omitted by a successful adapter discovery pass without
     3292     * allowing stale origins to continue importing. Their immutable adapter
     3293     * identity, snapshot, checkpoint, events and failure ledger are untouched.
     3294     */
     3295    private function mark_missing_generic_historical_origins_unavailable($source_id, $adapter_key, $discovered_origin_keys, $discovery_lock = null) {
     3296        global $wpdb;
     3297
     3298        $source_id = absint($source_id);
     3299        $adapter_key = sanitize_key((string) $adapter_key);
     3300        if (!$source_id || $adapter_key === '' || !is_array($discovered_origin_keys)) {
     3301            return ['status' => 'invalid_input', 'updated' => 0];
     3302        }
     3303
     3304        $discovered = [];
     3305        foreach ($discovered_origin_keys as $origin_key) {
     3306            $origin_key = sanitize_key((string) $origin_key);
     3307            if ($origin_key !== '') {
     3308                $discovered[$origin_key] = true;
     3309            }
     3310        }
     3311
     3312        if (!is_array($discovery_lock) || !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3313            return ['status' => 'lock_session_lost', 'updated' => 0, 'error' => 'Discovery lock session is no longer current.'];
     3314        }
     3315
     3316        $transaction_started = false;
     3317        $transaction_committed = false;
     3318        try {
     3319            // Reconciliation is one logical inventory operation. Keep it atomic so
     3320            // a database error halfway through cannot leave only part of the omitted
     3321            // origins marked unavailable. A later discovery can safely retry it.
     3322            $begin_result = statpl_db_query('START TRANSACTION');
     3323            if ($begin_result === false || $wpdb->last_error !== '') {
     3324                return ['status' => 'database_error', 'updated' => 0, 'error' => $wpdb->last_error];
     3325            }
     3326            $transaction_started = true;
     3327
     3328            if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3329                return ['status' => 'lock_session_lost', 'updated' => 0, 'error' => 'Discovery lock session changed before origin inventory read.'];
     3330            }
     3331            $existing = statpl_db_get_results(
     3332                statpl_db_prepare(
     3333                    "SELECT origin_key, status
     3334                     FROM {$this->view_source_origins_table}
     3335                     WHERE source_id = %d AND adapter_key = %s
     3336                     FOR UPDATE",
     3337                    $source_id,
     3338                    $adapter_key
     3339                ),
     3340                ARRAY_A
     3341            );
     3342            if (!empty($wpdb->last_error) || !is_array($existing)) {
     3343                return ['status' => 'database_error', 'updated' => 0, 'error' => $wpdb->last_error !== '' ? $wpdb->last_error : 'Origin inventory query returned an invalid result.'];
     3344            }
     3345
     3346            $updated = 0;
     3347            foreach ($existing as $row) {
     3348                if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3349                    return ['status' => 'lock_session_lost', 'updated' => 0, 'error' => 'Discovery lock session changed during origin inventory reconciliation.'];
     3350                }
     3351                $origin_key = isset($row['origin_key']) ? (string) $row['origin_key'] : '';
     3352                if ($origin_key === '' || isset($discovered[$origin_key])) {
     3353                    continue;
     3354                }
     3355
     3356                // Terminal lifecycle states are retained. Detection becomes unknown
     3357                // until the provider declares this origin again; active work becomes
     3358                // unavailable so stale adapter inventories cannot process it.
     3359                $updates = [
     3360                    'detection_status' => 'unknown',
     3361                    'updated_at' => current_time('mysql'),
     3362                ];
     3363                $formats = ['%s', '%s'];
     3364                if (in_array($row['status'] ?? '', ['pending', 'snapshot_ready', 'importing', 'retry_wait'], true)) {
     3365                    $updates['status'] = 'unavailable';
     3366                    $updates['last_error'] = 'This origin was not declared by the adapter during the latest successful discovery pass; its saved snapshot and checkpoint were preserved.';
     3367                    $formats = ['%s', '%s', '%s', '%s'];
     3368                }
     3369
     3370                $result = statpl_db_update(
     3371                    $this->view_source_origins_table,
     3372                    $updates,
     3373                    ['source_id' => $source_id, 'origin_key' => $origin_key],
     3374                    $formats,
     3375                    ['%d', '%s']
     3376                );
     3377                if ($result === false || $wpdb->last_error !== '') {
     3378                    return ['status' => 'database_error', 'updated' => 0, 'error' => $wpdb->last_error !== '' ? $wpdb->last_error : 'Origin reconciliation update failed.'];
     3379                }
     3380                $updated++;
     3381            }
     3382
     3383            if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3384                return ['status' => 'lock_session_lost', 'updated' => 0, 'error' => 'Discovery lock session changed before origin inventory commit.'];
     3385            }
     3386            $commit_result = statpl_db_query('COMMIT');
     3387            if ($commit_result === false || $wpdb->last_error !== '') {
     3388                return ['status' => 'database_error', 'updated' => 0, 'error' => $wpdb->last_error !== '' ? $wpdb->last_error : 'Origin reconciliation commit failed.'];
     3389            }
     3390            $transaction_committed = true;
     3391            if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3392                return ['status' => 'commit_outcome_unknown', 'updated' => 0, 'error' => 'Origin inventory commit completed but discovery lock session could not be confirmed.'];
     3393            }
     3394            return ['status' => 'updated', 'updated' => $updated];
     3395        } catch (Throwable $exception) {
     3396            return ['status' => 'database_error', 'updated' => 0, 'error' => $exception->getMessage()];
     3397        } finally {
     3398            // Includes thrown exceptions and ambiguous failures before COMMIT.
     3399            // A rollback after a successful commit is intentionally avoided.
     3400            if ($transaction_started && !$transaction_committed && $this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3401                try {
     3402                    statpl_db_query('ROLLBACK');
     3403                } catch (Throwable $rollback_exception) {
     3404                    $this->log_generic_historical_scheduler_error('origin_inventory_rollback', $rollback_exception->getMessage());
     3405                }
     3406            }
     3407        }
     3408    }
     3409
     3410    /** Validate the minimum public adapter contract without trusting a provider. */
     3411    private function is_valid_historical_view_adapter($adapter) {
     3412        foreach ([
     3413            'get_adapter_key',
     3414            'get_source_key',
     3415            'get_display_name',
     3416            'get_adapter_version',
     3417            'discover_origins',
     3418            'get_origin_snapshot',
     3419            'get_product_view_batch',
     3420            'normalize_record',
     3421        ] as $method) {
     3422            if (!is_callable([$adapter, $method])) {
     3423                return false;
     3424            }
     3425        }
     3426
     3427        return true;
     3428    }
     3429
     3430    /**
     3431     * Recompute the persisted status of one generic source from all its origins.
     3432     * This is intentionally called only by the generic pipeline; the independent
     3433     * SlimStat lifecycle and its status model are left untouched.
     3434     */
     3435    private function refresh_generic_historical_source_status($source_id) {
     3436        global $wpdb;
     3437
     3438        $source_id = absint($source_id);
     3439        if (!$source_id) {
     3440            return ['status' => 'invalid_source'];
     3441        }
     3442
     3443        // Serialize aggregate status calculation and persistence per source.
     3444        // Use the shared lock contract so the owning connection is captured
     3445        // atomically and RELEASE_LOCK is never attempted on a replacement session.
     3446        $status_lock_name = 'statpl_status_' . substr(md5((string) $source_id), 0, 32);
     3447        $status_lock = $this->acquire_generic_advisory_lock($status_lock_name);
     3448        if (empty($status_lock['acquired'])) {
     3449            if (($status_lock['status'] ?? '') === 'lock_busy_or_unavailable' && empty($status_lock['error'])) {
     3450                return ['status' => 'status_lock_busy'];
     3451            }
     3452            return ['status' => 'database_error', 'error' => (string) ($status_lock['error'] ?? 'Unable to acquire source status lock.')];
     3453        }
     3454
     3455        try {
     3456        if (!$this->generic_advisory_lock_session_is_current($status_lock)) {
     3457            return ['status' => 'status_lock_lost'];
     3458        }
     3459        $origins = statpl_db_get_results(statpl_db_prepare(
     3460            "SELECT detection_status, status FROM {$this->view_source_origins_table} WHERE source_id = %d",
     3461            $source_id
     3462        ), ARRAY_A);
     3463        if (!empty($wpdb->last_error)) {
     3464            return ['status' => 'database_error', 'error' => $wpdb->last_error];
     3465        }
     3466        if (!$this->generic_advisory_lock_session_is_current($status_lock)) {
     3467            return ['status' => 'status_lock_lost'];
     3468        }
     3469        if (!is_array($origins) || !$origins) {
     3470            return ['status' => 'no_origins'];
     3471        }
     3472
     3473        $available_origins = array_values(array_filter($origins, function ($origin) {
     3474            // Detection describes what the provider reported on its last pass;
     3475            // lifecycle status additionally records whether this exact persisted
     3476            // adapter identity is currently safe to execute. A replacement
     3477            // adapter can be detected as available while its origin remains
     3478            // deliberately unavailable because it cannot inherit the old cursor.
     3479            return isset($origin['detection_status'], $origin['status'])
     3480                && $origin['detection_status'] === 'available'
     3481                && $origin['status'] !== 'unavailable';
     3482        }));
     3483        $available = count($available_origins);
     3484        $available_completed = count(array_filter($available_origins, function ($origin) {
     3485            return isset($origin['status']) && $origin['status'] === 'completed';
     3486        }));
     3487        // Only available origins participate in import lifecycle aggregation.
     3488        // An unavailable/detected sibling must never keep an otherwise finished
     3489        // source stuck in importing or retry_wait.
     3490        $available_statuses = array_column($available_origins, 'status');
     3491
     3492        // A source remains actively importing while ANY available origin is
     3493        // still progressing. A blocked sibling origin must not make the whole
     3494        // source appear terminal before the remaining origins finish.
     3495        if (in_array('importing', $available_statuses, true) || in_array('snapshot_ready', $available_statuses, true) || in_array('pending', $available_statuses, true)) {
     3496            $source_status = 'importing';
     3497        } elseif (in_array('retry_wait', $available_statuses, true)) {
     3498            $source_status = 'retry_wait';
     3499        } elseif (in_array('needs_attention', $available_statuses, true)) {
     3500            $source_status = 'needs_attention';
     3501        } elseif ($available === 0) {
     3502            $source_status = 'unavailable';
     3503        } elseif ($available_completed === $available) {
     3504            // A source with unavailable origins is only partial, never fully complete.
     3505            $source_status = ($available === count($origins)) ? 'completed' : 'partial';
     3506        } elseif (in_array('completed', $available_statuses, true)) {
     3507            $source_status = 'partial';
     3508        } else {
     3509            $source_status = 'detected';
     3510        }
     3511
     3512        // Status reconciliation is not a source-registration/content update.
     3513        // Keep updated_at reserved for genuine source changes; otherwise the
     3514        // recovery scanner would generate needless writes on every request.
     3515        if (!$this->generic_advisory_lock_session_is_current($status_lock)) {
     3516            return ['status' => 'status_lock_lost'];
     3517        }
     3518        // Bind source-status persistence to the same MySQL session that owns
     3519        // this status lock. A PHP-side check alone leaves a reconnect race between
     3520        // validation and UPDATE; the SQL predicate closes that window.
     3521        if (empty($status_lock['lock_name']) || !$this->generic_advisory_lock_session_is_current($status_lock)) {
     3522            return ['status' => 'status_lock_lost'];
     3523        }
     3524        $updated = statpl_db_query(statpl_db_prepare(
     3525            "UPDATE {$this->view_sources_table}
     3526             SET status = %s
     3527             WHERE id = %d AND IS_USED_LOCK(%s) = CONNECTION_ID()",
     3528            $source_status,
     3529            $source_id,
     3530            (string) $status_lock['lock_name']
     3531        ));
     3532        if ($updated === false || $wpdb->last_error !== '') {
     3533            return ['status' => 'database_error', 'error' => $wpdb->last_error];
     3534        }
     3535        if (!$this->generic_advisory_lock_session_is_current($status_lock)) {
     3536            return ['status' => 'status_lock_lost'];
     3537        }
     3538
     3539        // Do not treat an accepted UPDATE as durable source-state convergence.
     3540        // The row may have been unchanged (0 affected rows), or an ambiguous
     3541        // acknowledgement may have occurred. Read the aggregate status back and
     3542        // require an exact match before reporting success to callers.
     3543        $persisted_status = statpl_db_get_var(statpl_db_prepare(
     3544            "SELECT status FROM {$this->view_sources_table} WHERE id = %d",
     3545            $source_id
     3546        ));
     3547        if ($wpdb->last_error !== '') {
     3548            return ['status' => 'database_error', 'error' => $wpdb->last_error];
     3549        }
     3550        if (!$this->generic_advisory_lock_session_is_current($status_lock)) {
     3551            return ['status' => 'status_lock_lost'];
     3552        }
     3553        if ($persisted_status === null) {
     3554            // The source may have been removed after the bounded scan selected it.
     3555            // Treat that as a vanished work item, not as a database failure.
     3556            return ['status' => 'source_missing'];
     3557        }
     3558        if (!is_string($persisted_status) || $persisted_status !== $source_status) {
     3559            return [
     3560                'status' => 'state_unverified',
     3561                'expected_status' => $source_status,
     3562                'persisted_status' => is_string($persisted_status) ? $persisted_status : null,
     3563            ];
     3564        }
     3565
     3566        return ['status' => 'updated', 'source_status' => $source_status];
     3567        } finally {
     3568            // Release only if the original connection still owns this lock.
     3569            $this->release_generic_advisory_lock($status_lock, 'source_status_reconciliation');
     3570        }
     3571    }
     3572
     3573    /** Retry adapter discovery and snapshot initialization after a bounded delay. */
     3574    public function retry_generic_historical_view_snapshot($source_id = 0, $origin_key = '') {
     3575        $source_id = absint($source_id);
     3576        $origin_key = sanitize_key((string) $origin_key);
     3577        if (!$source_id || $origin_key === '') {
     3578            return ['status' => 'invalid_input'];
     3579        }
     3580
     3581        // Discovery re-resolves the provider in this request and retries only origins
     3582        // whose immutable snapshot has not yet been persisted. Existing snapshots are
     3583        // protected by save_generic_historical_snapshot_if_empty().
     3584        $result = $this->discover_historical_view_adapters();
     3585        $state = $this->get_view_source_origin_state($source_id, $origin_key);
     3586        if (!empty($state['status']) && $state['status'] === 'found'
     3587            && is_array($state['snapshot']) && !empty($state['snapshot'])) {
     3588            // The discovery worker owns the snapshot failure ledger lifecycle. This
     3589            // follow-up request has no discovery lock context, so it must not delete
     3590            // the ledger row outside that ownership boundary.
     3591            $this->schedule_generic_historical_view_import_batch(15, $source_id, $origin_key);
     3592            $this->refresh_generic_historical_source_status($source_id);
     3593            return ['status' => 'snapshot_ready', 'discovery' => $result];
     3594        }
     3595
     3596        $this->refresh_generic_historical_source_status($source_id);
     3597        return ['status' => 'snapshot_still_unavailable', 'discovery' => $result];
     3598    }
     3599
     3600    /** Schedule bounded retries for transient snapshot/provider initialization failures. */
     3601    private function schedule_generic_historical_snapshot_retry($delay, $source_id, $origin_key) {
     3602        $args = [absint($source_id), sanitize_key((string) $origin_key)];
     3603        $hook = 'statpl_generic_view_snapshot_retry';
     3604        $requested_timestamp = time() + max(1, (int) $delay);
     3605
     3606        if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) {
     3607            $existing_timestamp = false;
     3608            if (function_exists('as_next_scheduled_action')) {
     3609                try {
     3610                    $existing_timestamp = as_next_scheduled_action($hook, $args, 'statpl-plus');
     3611                } catch (Throwable $exception) {
     3612                    $this->log_generic_historical_scheduler_error($hook . '_lookup', $exception->getMessage());
     3613                }
     3614            }
     3615
     3616            // A newly discovered transient failure may have a shorter backoff than
     3617            // an older snapshot retry. Keep the earliest deadline, not merely any
     3618            // existing action, so stale long delays cannot postpone recovery.
     3619            if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) {
     3620                return true;
     3621            }
     3622            if ($existing_timestamp && function_exists('as_unschedule_all_actions')) {
     3623                try {
     3624                    as_unschedule_all_actions($hook, $args, 'statpl-plus');
     3625                } catch (Throwable $exception) {
     3626                    $this->log_generic_historical_scheduler_error($hook . '_unschedule', $exception->getMessage());
     3627                    return true;
     3628                }
     3629            } elseif ($existing_timestamp) {
     3630                return true;
     3631            } elseif (function_exists('as_has_scheduled_action')) {
     3632                try {
     3633                    if (as_has_scheduled_action($hook, $args, 'statpl-plus')) {
     3634                        // Without a reliable timestamp/removal API, retain the
     3635                        // existing action rather than creating an uncontrolled duplicate.
     3636                        return true;
     3637                    }
     3638                } catch (Throwable $exception) {
     3639                    $this->log_generic_historical_scheduler_error($hook . '_presence_check', $exception->getMessage());
     3640                }
     3641            }
     3642
     3643            try {
     3644                $action_id = as_schedule_single_action($requested_timestamp, $hook, $args, 'statpl-plus');
     3645                if ($action_id) {
     3646                    return true;
     3647                }
     3648            } catch (Throwable $exception) {
     3649                $this->log_generic_historical_scheduler_error($hook, $exception->getMessage());
     3650            }
     3651            // Action Scheduler can be unavailable or fail transiently. Fall back to WP-Cron.
     3652        }
     3653
     3654        // WP-Cron follows the same earliest-deadline rule. If replacing a later
     3655        // event fails, retain it rather than creating a duplicate with uncertain order.
     3656        try {
     3657            $existing_timestamp = wp_next_scheduled($hook, $args);
     3658            if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) {
     3659                return true;
     3660            }
     3661            if ($existing_timestamp) {
     3662                $unscheduled = wp_unschedule_event((int) $existing_timestamp, $hook, $args);
     3663                if (!$unscheduled || is_wp_error($unscheduled)) {
     3664                    $this->log_generic_historical_scheduler_error(
     3665                        $hook . '_unschedule',
     3666                        is_wp_error($unscheduled) ? $unscheduled->get_error_message() : 'Could not replace the later WP-Cron event.'
     3667                    );
     3668                    return true;
     3669                }
     3670            }
     3671            $scheduled = wp_schedule_single_event($requested_timestamp, $hook, $args);
     3672            if (!$scheduled || is_wp_error($scheduled)) {
     3673                $this->log_generic_historical_scheduler_error($hook, is_wp_error($scheduled) ? $scheduled->get_error_message() : 'WP-Cron rejected the event.');
     3674                return false;
     3675            }
     3676            return true;
     3677        } catch (Throwable $exception) {
     3678            $this->log_generic_historical_scheduler_error($hook, $exception->getMessage());
     3679            return false;
     3680        }
     3681    }
     3682
     3683    /** Requeue persisted generic-import work after a previous scheduler outage. */
     3684    private function recover_unscheduled_generic_historical_work() {
     3685        global $wpdb;
     3686
     3687        $origins = statpl_db_get_results(
     3688            "SELECT source_id, origin_key, snapshot_data, status, detection_status
     3689             FROM {$this->view_source_origins_table}
     3690             WHERE detection_status = 'available'
     3691               AND status IN ('pending', 'snapshot_ready', 'importing', 'retry_wait')
     3692             ORDER BY updated_at ASC
     3693             LIMIT 50",
     3694            ARRAY_A
     3695        );
     3696        if (!is_array($origins) || !empty($wpdb->last_error)) {
     3697            $this->log_generic_historical_scheduler_error('recovery_scan', !empty($wpdb->last_error) ? $wpdb->last_error : 'Unable to read pending origins.');
     3698            return 0;
     3699        }
     3700
     3701        $requeued = 0;
     3702        foreach ($origins as $origin) {
     3703            $source_id = absint($origin['source_id'] ?? 0);
     3704            $origin_key = sanitize_key((string) ($origin['origin_key'] ?? ''));
     3705            if (!$source_id || $origin_key === '') {
     3706                continue;
     3707            }
     3708
     3709            if (empty($origin['snapshot_data'])) {
     3710                $scheduled = $this->schedule_generic_historical_snapshot_retry(300, $source_id, $origin_key);
     3711            } else {
     3712                $scheduled = $this->schedule_generic_historical_view_import_batch(300, $source_id, $origin_key);
     3713            }
     3714            if ($scheduled) {
     3715                $requeued++;
     3716            }
     3717        }
     3718
     3719        // Retry rows are durable in the failure ledger. Recreate their worker
     3720        // trigger independently of the main import cursor.
     3721        $retry_origins = statpl_db_get_results(
     3722            "SELECT source_id, origin_key, MIN(next_retry_at) AS next_retry_at
     3723             FROM {$this->view_source_failures_table}
     3724             WHERE status = 'retry'
     3725               AND retry_checkpoint IS NOT NULL
     3726               AND retry_checkpoint <> ''
     3727             GROUP BY source_id, origin_key
     3728             ORDER BY MIN(next_retry_at) ASC
     3729             LIMIT 50",
     3730            ARRAY_A
     3731        );
     3732        if (is_array($retry_origins) && empty($wpdb->last_error)) {
     3733            foreach ($retry_origins as $retry_origin) {
     3734                $source_id = absint($retry_origin['source_id'] ?? 0);
     3735                $origin_key = sanitize_key((string) ($retry_origin['origin_key'] ?? ''));
     3736                if (!$source_id || $origin_key === '') {
     3737                    continue;
     3738                }
     3739                $next_timestamp = !empty($retry_origin['next_retry_at']) ? strtotime((string) $retry_origin['next_retry_at']) : false;
     3740                $delay = $next_timestamp !== false ? max(1, $next_timestamp - current_time('timestamp')) : 300;
     3741                if ($this->schedule_generic_historical_failure_retry(min(86400, $delay), $source_id, $origin_key)) {
     3742                    $requeued++;
     3743                }
     3744            }
     3745        } elseif (!empty($wpdb->last_error)) {
     3746            $this->log_generic_historical_scheduler_error('retry_recovery_scan', $wpdb->last_error);
     3747        }
     3748
     3749        // Serialize the cursor read, bounded page, and cursor write. Per-source
     3750        // locks protect each aggregate update; this scan-level lock additionally
     3751        // prevents concurrent requests from moving the persistent cursor backwards.
     3752        // Use the shared lock helper so acquisition, ownership checks and release
     3753        // follow the same connection-identity contract as the other workers.
     3754        $scan_lock_name = 'statpl_srcscan_' . substr(md5((string) $wpdb->prefix), 0, 32);
     3755        $scan_lock = $this->acquire_generic_advisory_lock($scan_lock_name);
     3756        if (empty($scan_lock['acquired'])) {
     3757            $scan_status = sanitize_key((string) ($scan_lock['status'] ?? 'lock_unavailable'));
     3758            if ($scan_status !== 'lock_busy_or_unavailable') {
     3759                $this->log_generic_historical_scheduler_error(
     3760                    'source_status_scan_lock',
     3761                    (string) ($scan_lock['error'] ?? $scan_status)
     3762                );
     3763            }
     3764        } else {
     3765            try {
     3766                if (!$this->generic_advisory_lock_session_is_current($scan_lock)) {
     3767                    $this->log_generic_historical_scheduler_error(
     3768                        'source_status_scan_lock',
     3769                        'The MySQL connection changed immediately after acquiring the source-status scan lock; this scan was skipped.'
     3770                    );
     3771                } else {
     3772                    // Source-status reconciliation is durable work even when every
     3773                    // origin is already completed. A persistent keyset cursor ensures
     3774                    // that a busy or failing source cannot pin the first page and
     3775                    // starve later sources when more than 50 sources exist.
     3776                    $cursor_option = 'statpl_generic_status_recovery_cursor';
     3777                    // The cursor is written directly with SQL while proving lock
     3778                    // ownership. Read it directly too: get_option() may return a
     3779                    // stale object-cache value because direct SQL bypasses the
     3780                    // WordPress options cache.
     3781                    $cursor_value = statpl_db_get_var(statpl_db_prepare(
     3782                        "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
     3783                        $cursor_option
     3784                    ));
     3785                    if ($wpdb->last_error !== '') {
     3786                        $this->log_generic_historical_scheduler_error('source_status_recovery_cursor', $wpdb->last_error);
     3787                        $cursor_value = '0';
     3788                    }
     3789                    $source_cursor = absint($cursor_value);
     3790                    $sources = statpl_db_get_results(statpl_db_prepare(
     3791                        "SELECT sources.id FROM {$this->view_sources_table} AS sources
     3792                         WHERE sources.id > %d
     3793                           AND EXISTS (
     3794                               SELECT 1 FROM {$this->view_source_origins_table} AS origins
     3795                               WHERE origins.source_id = sources.id
     3796                           )
     3797                         ORDER BY sources.id ASC
     3798                         LIMIT 50",
     3799                        $source_cursor
     3800                    ), ARRAY_A);
     3801
     3802                    if (!is_array($sources) || !empty($wpdb->last_error)) {
     3803                        $this->log_generic_historical_scheduler_error(
     3804                            'source_status_recovery_scan',
     3805                            !empty($wpdb->last_error) ? $wpdb->last_error : 'Unable to read generic sources for status reconciliation.'
     3806                        );
     3807                    } elseif ($this->generic_advisory_lock_session_is_current($scan_lock)) {
     3808                        // Wrap to the beginning only after reaching the end of the source set.
     3809                        $wrapped_to_start = false;
     3810                        if (!$sources && $source_cursor > 0) {
     3811                            $source_cursor = 0;
     3812                            $wrapped_to_start = true;
     3813                            $sources = statpl_db_get_results(
     3814                                "SELECT sources.id FROM {$this->view_sources_table} AS sources
     3815                                 WHERE EXISTS (
     3816                                     SELECT 1 FROM {$this->view_source_origins_table} AS origins
     3817                                     WHERE origins.source_id = sources.id
     3818                                 )
     3819                                 ORDER BY sources.id ASC
     3820                                 LIMIT 50",
     3821                                ARRAY_A
     3822                            );
     3823                            if (!is_array($sources) || !empty($wpdb->last_error)) {
     3824                                $this->log_generic_historical_scheduler_error(
     3825                                    'source_status_recovery_scan',
     3826                                    !empty($wpdb->last_error) ? $wpdb->last_error : 'Unable to restart generic source status scan.'
     3827                                );
     3828                                $sources = false;
     3829                            }
     3830                        }
     3831
     3832                        if (is_array($sources) && $sources) {
     3833                            $last_scanned_source_id = $source_cursor;
     3834                            $scan_page_completed = true;
     3835                            foreach ($sources as $source) {
     3836                                // If wpdb transparently reconnects, the connection-scoped
     3837                                // scan lock is gone. Stop and leave the cursor unchanged;
     3838                                // already attempted rows are safe to revisit.
     3839                                if (!$this->generic_advisory_lock_session_is_current($scan_lock)) {
     3840                                    $scan_page_completed = false;
     3841                                    $this->log_generic_historical_scheduler_error(
     3842                                        'source_status_scan_lock',
     3843                                        'The MySQL connection changed during source reconciliation; the cursor was not advanced.'
     3844                                    );
     3845                                    break;
     3846                                }
     3847
     3848                                $source_id = absint($source['id'] ?? 0);
     3849                                if (!$source_id) {
     3850                                    continue;
     3851                                }
     3852                                $last_scanned_source_id = max($last_scanned_source_id, $source_id);
     3853                                try {
     3854                                    $reconciliation = $this->refresh_generic_historical_source_status($source_id);
     3855                                    if (!in_array($reconciliation['status'] ?? '', ['updated', 'status_lock_busy', 'source_missing'], true)) {
     3856                                        $this->log_generic_historical_scheduler_error(
     3857                                            'source_status_recovery',
     3858                                            'Source ' . $source_id . ' status reconciliation did not verify: ' . sanitize_key((string) ($reconciliation['status'] ?? 'unknown'))
     3859                                        );
     3860                                    }
     3861                                } catch (Throwable $exception) {
     3862                                    // One corrupt or temporarily failing source must not prevent
     3863                                    // the cursor from reaching the remaining sources.
     3864                                    $this->log_generic_historical_scheduler_error(
     3865                                        'source_status_recovery',
     3866                                        'Source ' . $source_id . ' reconciliation threw: ' . $exception->getMessage()
     3867                                    );
     3868                                }
     3869                            }
     3870
     3871                            // Advance only after the complete bounded page was attempted
     3872                            // and the original scan-lock session is still alive. A source
     3873                            // failure or lock contention is attempted work, not a reason
     3874                            // to starve later IDs; a lost scan session is different.
     3875                            if ($scan_page_completed && $this->generic_advisory_lock_session_is_current($scan_lock)) {
     3876                                // Persist the cursor only in a SQL statement that proves
     3877                                // this exact connection still owns the scan lock. A PHP-
     3878                                // level check followed by update_option() is insufficient:
     3879                                // wpdb may reconnect between those calls and a stale scan
     3880                                // could overwrite the cursor of a newer lock owner.
     3881                                $cursor_persisted = statpl_db_query(statpl_db_prepare(
     3882                                    "INSERT INTO {$wpdb->options} (option_name, option_value, autoload)
     3883                                     SELECT %s, %s, 'no'
     3884                                     WHERE IS_USED_LOCK(%s) = CONNECTION_ID()
     3885                                     ON DUPLICATE KEY UPDATE option_value = IF(
     3886                                         IS_USED_LOCK(%s) = CONNECTION_ID(),
     3887                                         VALUES(option_value),
     3888                                         option_value
     3889                                     )",
     3890                                    $cursor_option,
     3891                                    (string) $last_scanned_source_id,
     3892                                    $scan_lock_name,
     3893                                    $scan_lock_name
     3894                                ));
     3895                                if (!in_array($cursor_persisted, [0, 1, 2], true)) {
     3896                                    $this->log_generic_historical_scheduler_error(
     3897                                        'source_status_recovery_cursor',
     3898                                        !empty($wpdb->last_error) ? $wpdb->last_error : 'Could not persist the source-status recovery cursor while owning the scan lock.'
     3899                                    );
     3900                                }
     3901                                if (!$this->generic_advisory_lock_session_is_current($scan_lock)) {
     3902                                    $this->log_generic_historical_scheduler_error(
     3903                                        'source_status_recovery_cursor',
     3904                                        'The MySQL connection changed while persisting the cursor; the next scan will safely re-evaluate the cursor.'
     3905                                    );
     3906                                } else {
     3907                                    $persisted_cursor_value = statpl_db_get_var(statpl_db_prepare(
     3908                                        "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
     3909                                        $cursor_option
     3910                                    ));
     3911                                    $persisted_cursor = absint($persisted_cursor_value);
     3912                                    if (!empty($wpdb->last_error) || $persisted_cursor !== $last_scanned_source_id) {
     3913                                        $this->log_generic_historical_scheduler_error('source_status_recovery_cursor', 'Could not verify the source-status recovery cursor.');
     3914                                    }
     3915                                }
     3916                            }
     3917                        } elseif (is_array($sources) && !$sources && $wrapped_to_start) {
     3918                            if ($this->generic_advisory_lock_session_is_current($scan_lock)) {
     3919                                // Reset only while the current session still owns the
     3920                                // scan lock; the predicate is checked atomically by SQL.
     3921                                $reset_result = statpl_db_query(statpl_db_prepare(
     3922                                    "INSERT INTO {$wpdb->options} (option_name, option_value, autoload)
     3923                                     SELECT %s, '0', 'no'
     3924                                     WHERE IS_USED_LOCK(%s) = CONNECTION_ID()
     3925                                     ON DUPLICATE KEY UPDATE option_value = IF(
     3926                                         IS_USED_LOCK(%s) = CONNECTION_ID(), '0', option_value
     3927                                     )",
     3928                                    $cursor_option,
     3929                                    $scan_lock_name,
     3930                                    $scan_lock_name
     3931                                ));
     3932                                if ($reset_result === false || !empty($wpdb->last_error)) {
     3933                                    $this->log_generic_historical_scheduler_error(
     3934                                        'source_status_recovery_cursor',
     3935                                        !empty($wpdb->last_error) ? $wpdb->last_error : 'Could not reset the source-status recovery cursor.'
     3936                                    );
     3937                                } elseif ($this->generic_advisory_lock_session_is_current($scan_lock)) {
     3938                                    $reset_cursor = statpl_db_get_var(statpl_db_prepare(
     3939                                        "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
     3940                                        $cursor_option
     3941                                    ));
     3942                                    if (!empty($wpdb->last_error) || absint($reset_cursor) !== 0) {
     3943                                        $this->log_generic_historical_scheduler_error(
     3944                                            'source_status_recovery_cursor',
     3945                                            'Could not verify the reset source-status recovery cursor.'
     3946                                        );
     3947                                    }
     3948                                }
     3949                            }
     3950                        }
     3951                    } else {
     3952                        $this->log_generic_historical_scheduler_error(
     3953                            'source_status_scan_lock',
     3954                            'The MySQL connection changed while reading the source-status page; this page was discarded.'
     3955                        );
     3956                    }
     3957                }
     3958            } catch (Throwable $exception) {
     3959                $this->log_generic_historical_scheduler_error('source_status_recovery_scan', $exception->getMessage());
     3960            } finally {
     3961                $this->release_generic_advisory_lock($scan_lock, 'source_status_recovery_scan');
     3962            }
     3963        }
     3964
     3965        return $requeued;
     3966    }
     3967
     3968    /** Write diagnostic messages only when WordPress debugging is enabled. */
     3969    private function debug_log($message) {
     3970        if (defined('WP_DEBUG') && WP_DEBUG) {
     3971            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- This diagnostic logger writes only when the site owner explicitly enables WP_DEBUG.
     3972            error_log((string) $message);
     3973        }
     3974    }
     3975
     3976    /** Log scheduler failures without allowing diagnostics to interrupt tracking. */
     3977    private function log_generic_historical_scheduler_error($hook, $message) {
     3978        $this->debug_log('[Statora Plus] Historical scheduler failure for ' . sanitize_key((string) $hook) . ': ' . sanitize_text_field((string) $message));
     3979    }
     3980
     3981    /** Schedule retries for deferred records independently from the main import cursor. */
     3982    private function schedule_generic_historical_failure_retry($delay, $source_id, $origin_key) {
     3983        $args = [absint($source_id), sanitize_key((string) $origin_key)];
     3984        $hook = 'statpl_generic_view_retry_failed_records';
     3985        $requested_timestamp = time() + max(1, (int) $delay);
     3986
     3987        if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) {
     3988            $existing_timestamp = false;
     3989            if (function_exists('as_next_scheduled_action')) {
     3990                try {
     3991                    $existing_timestamp = as_next_scheduled_action($hook, $args, 'statpl-plus');
     3992                } catch (Throwable $exception) {
     3993                    $this->log_generic_historical_scheduler_error($hook . '_lookup', $exception->getMessage());
     3994                }
     3995            }
     3996
     3997            // Keep an earlier/equal action, but replace a later one. Otherwise a
     3998            // newly-created short-backoff failure could wait behind an older,
     3999            // long-backoff retry for the same source-origin.
     4000            if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) {
     4001                return true;
     4002            }
     4003            if ($existing_timestamp && function_exists('as_unschedule_all_actions')) {
     4004                try {
     4005                    as_unschedule_all_actions($hook, $args, 'statpl-plus');
     4006                } catch (Throwable $exception) {
     4007                    // The existing action may still be queued. Retain it rather
     4008                    // than create a duplicate whose execution order is unknown.
     4009                    $this->log_generic_historical_scheduler_error($hook . '_unschedule', $exception->getMessage());
     4010                    return true;
     4011                }
     4012            } elseif (function_exists('as_has_scheduled_action')) {
     4013                try {
     4014                    if (as_has_scheduled_action($hook, $args, 'statpl-plus')) {
     4015                        // Without reliable timestamp/removal APIs, retain the
     4016                        // existing action rather than duplicate the worker.
     4017                        return true;
     4018                    }
     4019                } catch (Throwable $exception) {
     4020                    $this->log_generic_historical_scheduler_error($hook . '_presence_check', $exception->getMessage());
     4021                }
     4022            }
     4023            try {
     4024                $action_id = as_schedule_single_action($requested_timestamp, $hook, $args, 'statpl-plus');
     4025                if ($action_id) {
     4026                    return true;
     4027                }
     4028            } catch (Throwable $exception) {
     4029                $this->log_generic_historical_scheduler_error($hook, $exception->getMessage());
     4030            }
     4031            // Fall through to WP-Cron if Action Scheduler did not accept the action.
     4032        }
     4033
     4034        try {
     4035            $existing_timestamp = wp_next_scheduled($hook, $args);
     4036            if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) {
     4037                return true;
     4038            }
     4039            if ($existing_timestamp) {
     4040                wp_unschedule_event((int) $existing_timestamp, $hook, $args);
     4041            }
     4042            $scheduled = wp_schedule_single_event($requested_timestamp, $hook, $args);
     4043            if (!$scheduled || is_wp_error($scheduled)) {
     4044                $this->log_generic_historical_scheduler_error($hook, is_wp_error($scheduled) ? $scheduled->get_error_message() : 'WP-Cron rejected the event.');
     4045                return false;
     4046            }
     4047            return true;
     4048        } catch (Throwable $exception) {
     4049            $this->log_generic_historical_scheduler_error($hook, $exception->getMessage());
     4050            return false;
     4051        }
     4052    }
     4053
     4054    /** Acquire a connection-scoped advisory lock and capture its owning session. */
     4055    private function acquire_generic_advisory_lock($lock_name) {
     4056        global $wpdb;
     4057        try {
     4058            $row = statpl_db_get_row(statpl_db_prepare(
     4059                'SELECT CONNECTION_ID() AS connection_id, GET_LOCK(%s, 0) AS lock_acquired',
     4060                (string) $lock_name
     4061            ), ARRAY_A);
     4062        } catch (Throwable $exception) {
     4063            return ['acquired' => false, 'status' => 'lock_error', 'error' => $exception->getMessage()];
     4064        }
     4065        if (!is_array($row) || !empty($wpdb->last_error) || (string) ($row['lock_acquired'] ?? '') !== '1' || empty($row['connection_id'])) {
     4066            return ['acquired' => false, 'status' => 'lock_busy_or_unavailable', 'error' => (string) $wpdb->last_error];
     4067        }
     4068        return ['acquired' => true, 'connection_id' => (string) $row['connection_id'], 'lock_name' => (string) $lock_name];
     4069    }
     4070
     4071    /** Roll back an open import transaction only on its original DB session. */
     4072    private function rollback_generic_import_transaction(&$transaction_open, $origin_lock) {
     4073        global $wpdb;
     4074        if (!$transaction_open) {
     4075            return true;
     4076        }
     4077        // Never issue transaction control on a transparently reconnected session.
     4078        // The caller may retry once from finally if this attempt fails.
     4079        for ($attempt = 0; $attempt < 1; $attempt++) {
     4080            if (!$this->generic_db_session_matches_lock($origin_lock)) {
     4081                return false;
     4082            }
     4083            $result = statpl_db_query('ROLLBACK');
     4084            if ($result !== false) {
     4085                $transaction_open = false;
     4086                return true;
     4087            }
     4088        }
     4089        return false;
     4090    }
     4091
     4092    /** Confirm the current wpdb connection is the session that acquired a lock, regardless of lock ownership. */
     4093    private function generic_db_session_matches_lock($lock) {
     4094        global $wpdb;
     4095        if (!is_array($lock) || empty($lock['acquired']) || empty($lock['connection_id'])) {
     4096            return false;
     4097        }
     4098        try {
     4099            $connection_id = statpl_db_get_var('SELECT CONNECTION_ID()');
     4100        } catch (Throwable $exception) {
     4101            return false;
     4102        }
     4103        return $connection_id !== null
     4104            && empty($wpdb->last_error)
     4105            && (string) $connection_id === (string) $lock['connection_id'];
     4106    }
     4107
     4108    /** Confirm the current wpdb session still owns the lock-acquiring connection. */
     4109    private function generic_advisory_lock_session_is_current($lock) {
     4110        global $wpdb;
     4111        if (!is_array($lock) || empty($lock['acquired']) || empty($lock['connection_id'])) {
     4112            return false;
     4113        }
     4114        try {
     4115            // Session identity alone is insufficient: code running on the same
     4116            // connection could have released or replaced the named lock. Verify
     4117            // both the connection and the actual lock owner in one SQL read.
     4118            $row = statpl_db_get_row(statpl_db_prepare(
     4119                'SELECT CONNECTION_ID() AS connection_id, IS_USED_LOCK(%s) AS lock_owner',
     4120                (string) $lock['lock_name']
     4121            ), ARRAY_A);
     4122        } catch (Throwable $exception) {
     4123            return false;
     4124        }
     4125        return is_array($row)
     4126            && empty($wpdb->last_error)
     4127            && (string) ($row['connection_id'] ?? '') === (string) $lock['connection_id']
     4128            && (string) ($row['lock_owner'] ?? '') === (string) $lock['connection_id'];
     4129    }
     4130
     4131    /** Release only on the original session; a lost session releases its own locks server-side. */
     4132    private function release_generic_advisory_lock($lock, $context = '') {
     4133        global $wpdb;
     4134        if (!$this->generic_advisory_lock_session_is_current($lock)) {
     4135            if (defined('WP_DEBUG') && WP_DEBUG) {
     4136                $this->debug_log('Statora Plus: advisory lock session changed before release' . ($context !== '' ? ' (' . sanitize_key($context) . ')' : '') . '.');
     4137            }
     4138            return false;
     4139        }
     4140        try {
     4141            $released = statpl_db_get_var(statpl_db_prepare('SELECT RELEASE_LOCK(%s)', (string) $lock['lock_name']));
     4142            if ((string) $released === '1' && empty($wpdb->last_error)) {
     4143                return true;
     4144            }
     4145        } catch (Throwable $exception) {
     4146            if (defined('WP_DEBUG') && WP_DEBUG) {
     4147                $this->debug_log('Statora Plus: advisory lock release failed' . ($context !== '' ? ' (' . sanitize_key($context) . ')' : '') . ': ' . $exception->getMessage());
     4148            }
     4149            return false;
     4150        }
     4151        if (defined('WP_DEBUG') && WP_DEBUG) {
     4152            $this->debug_log('Statora Plus: advisory lock release was not confirmed' . ($context !== '' ? ' (' . sanitize_key($context) . ')' : '') . '.');
     4153        }
     4154        return false;
     4155    }
     4156
     4157    /** Replay due failed records from their saved immutable batch checkpoint. */
     4158    public function retry_generic_historical_view_failures($source_id = 0, $origin_key = '') {
     4159        global $wpdb;
     4160        $source_id = absint($source_id);
     4161        $origin_key = sanitize_key((string) $origin_key);
     4162        if (!$source_id || $origin_key === '') {
     4163            return ['status' => 'invalid_input'];
     4164        }
     4165
     4166        $now = current_time('mysql');
     4167        $failure = statpl_db_get_row(statpl_db_prepare(
     4168            "SELECT retry_checkpoint, next_retry_at FROM {$this->view_source_failures_table}
     4169             WHERE source_id = %d AND origin_key = %s AND status = 'retry' AND next_retry_at <= %s
     4170               AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> ''
     4171             ORDER BY next_retry_at ASC, id ASC LIMIT 1",
     4172            $source_id,
     4173            $origin_key,
     4174            $now
     4175        ), ARRAY_A);
     4176        if (!empty($wpdb->last_error)) {
     4177            $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key);
     4178            return ['status' => 'failure_lookup_error'];
     4179        }
     4180        if (!$failure) {
     4181            $next = statpl_db_get_var(statpl_db_prepare(
     4182                "SELECT MIN(next_retry_at) FROM {$this->view_source_failures_table}
     4183                 WHERE source_id = %d AND origin_key = %s AND status = 'retry'
     4184                   AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> ''",
     4185                $source_id,
     4186                $origin_key
     4187            ));
     4188            if (!empty($wpdb->last_error)) {
     4189                // A failed deadline lookup is not evidence that the retry queue is
     4190                // empty. Preserve the worker trigger and avoid status aggregation
     4191                // from an incomplete view of the durable failure ledger.
     4192                $this->log_generic_historical_scheduler_error('retry_ledger_next_deadline', $wpdb->last_error);
     4193                $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key);
     4194                return ['status' => 'failure_ledger_deadline_failed'];
     4195            }
     4196            if ($next) {
     4197                $next_timestamp = strtotime((string) $next);
     4198                $delay = ($next_timestamp !== false) ? max(1, $next_timestamp - current_time('timestamp')) : 300;
     4199                $this->schedule_generic_historical_failure_retry(min(86400, $delay), $source_id, $origin_key);
     4200            }
     4201            $this->refresh_generic_historical_source_status($source_id);
     4202            return ['status' => 'no_due_failures'];
     4203        }
     4204
     4205        $lock_name = 'statpl_generic_' . substr(hash('sha256', $wpdb->prefix . ':' . $source_id . ':' . $origin_key), 0, 40);
     4206        $origin_lock = $this->acquire_generic_advisory_lock($lock_name);
     4207        if (empty($origin_lock['acquired'])) {
     4208            $this->schedule_generic_historical_failure_retry(30, $source_id, $origin_key);
     4209            return ['status' => 'worker_already_running'];
     4210        }
     4211
     4212        try {
     4213            // The initial lookup above only determines whether this worker appears
     4214            // eligible. Re-read the durable row after acquiring the same origin lock
     4215            // used by the main importer: another worker may have resolved or changed
     4216            // this retry while we were waiting. Never replay a stale checkpoint.
     4217            if (!$this->generic_advisory_lock_session_is_current($origin_lock)) {
     4218                $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key);
     4219                return ['status' => 'origin_lock_session_lost'];
     4220            }
     4221            $locked_failure = statpl_db_get_row(statpl_db_prepare(
     4222                "SELECT retry_checkpoint, next_retry_at FROM {$this->view_source_failures_table}
     4223                 WHERE source_id = %d AND origin_key = %s AND status = 'retry'
     4224                   AND next_retry_at <= %s AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> ''
     4225                 ORDER BY next_retry_at ASC, id ASC LIMIT 1",
     4226                $source_id,
     4227                $origin_key,
     4228                current_time('mysql')
     4229            ), ARRAY_A);
     4230            if (!empty($wpdb->last_error)) {
     4231                $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key);
     4232                return ['status' => 'failure_lookup_error'];
     4233            }
     4234            if (!$locked_failure) {
     4235                return ['status' => 'retry_no_longer_due'];
     4236            }
     4237
     4238            $retry_checkpoint = json_decode($locked_failure['retry_checkpoint'], true);
     4239            if (!is_array($retry_checkpoint) || !$retry_checkpoint) {
     4240                // Corrupt replay metadata is not silently discarded or counted as a view.
     4241                $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key);
     4242                return ['status' => 'invalid_retry_checkpoint'];
     4243            }
     4244
     4245            if (!$this->generic_advisory_lock_session_is_current($origin_lock)) {
     4246                $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key);
     4247                return ['status' => 'origin_lock_session_lost'];
     4248            }
     4249            $result = $this->process_generic_historical_view_import_batch($source_id, $origin_key, $retry_checkpoint, $origin_lock);
     4250        } finally {
     4251            $this->release_generic_advisory_lock($origin_lock, 'retry_origin');
     4252        }
     4253
     4254        // The processing lock above protects replay itself. Reacquire the same
     4255        // origin lock before reconciling ledger counts and origin status: the main
     4256        // importer may have started immediately after the replay lock was released.
     4257        // Re-reading under the lock prevents this worker from applying stale status
     4258        // decisions over a newer main-import checkpoint/state transition.
     4259        $post_origin_lock = $this->acquire_generic_advisory_lock($lock_name);
     4260        if (empty($post_origin_lock['acquired'])) {
     4261            $this->schedule_generic_historical_failure_retry(30, $source_id, $origin_key);
     4262            return ['status' => 'post_processing_lock_busy'];
     4263        }
     4264
     4265        try {
     4266        if (!$this->generic_advisory_lock_session_is_current($post_origin_lock)) {
     4267            $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key);
     4268            return ['status' => 'post_processing_lock_session_lost'];
     4269        }
     4270        $remaining_value = statpl_db_get_var(statpl_db_prepare(
     4271            "SELECT COUNT(*) FROM {$this->view_source_failures_table}
     4272             WHERE source_id = %d AND origin_key = %s AND status = 'retry'
     4273               AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> ''",
     4274            $source_id,
     4275            $origin_key
     4276        ));
     4277        if (!empty($wpdb->last_error) || $remaining_value === null) {
     4278            // A failed count must never be interpreted as an empty ledger. Keep
     4279            // the worker alive and let the next pass re-read durable retry rows.
     4280            $this->log_generic_historical_scheduler_error('retry_ledger_count', $wpdb->last_error ?: 'Retry ledger count returned NULL.');
     4281            $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key);
     4282            return ['status' => 'failure_ledger_count_failed'];
     4283        }
     4284        $remaining = (int) $remaining_value;
     4285        if ($remaining > 0) {
     4286            $next = statpl_db_get_var(statpl_db_prepare(
     4287                "SELECT MIN(next_retry_at) FROM {$this->view_source_failures_table}
     4288                 WHERE source_id = %d AND origin_key = %s AND status = 'retry'
     4289                   AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> ''",
     4290                $source_id,
     4291                $origin_key
     4292            ));
     4293            if (!empty($wpdb->last_error)) {
     4294                $this->log_generic_historical_scheduler_error('retry_ledger_deadline', $wpdb->last_error);
     4295                $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key);
     4296                return ['status' => 'failure_ledger_deadline_failed', 'remaining' => $remaining];
     4297            }
     4298            $next_timestamp = $next ? strtotime((string) $next) : false;
     4299            $delay = ($next_timestamp !== false) ? max(1, $next_timestamp - current_time('timestamp')) : 300;
     4300            $this->schedule_generic_historical_failure_retry(min(86400, $delay), $source_id, $origin_key);
     4301        } else {
     4302            $blocked_value = statpl_db_get_var(statpl_db_prepare(
     4303                "SELECT COUNT(*) FROM {$this->view_source_failures_table}
     4304                 WHERE source_id = %d AND origin_key = %s AND status = 'blocked'",
     4305                $source_id,
     4306                $origin_key
     4307            ));
     4308            if (!empty($wpdb->last_error) || $blocked_value === null) {
     4309                $this->log_generic_historical_scheduler_error('blocked_ledger_count', $wpdb->last_error ?: 'Blocked ledger count returned NULL.');
     4310                $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key);
     4311                return ['status' => 'failure_ledger_count_failed'];
     4312            }
     4313            $blocked = (int) $blocked_value;
     4314            $current_state = $this->get_view_source_origin_state($source_id, $origin_key);
     4315            $origin_status = $current_state['origin_status'] ?? '';
     4316            if (in_array($origin_status, ['pending', 'snapshot_ready', 'importing', 'retry_wait'], true)) {
     4317                // The retry worker cannot infer whether the main scan has reached
     4318                // the immutable snapshot boundary. This remains true when some
     4319                // records are already blocked: the main cursor may still have
     4320                // later batches to import. Resume the main scan and let its own
     4321                // has_more result, together with the durable ledger counts, decide
     4322                // whether the final state is completed or needs_attention.
     4323                $updates = [
     4324                    'status' => 'importing',
     4325                    'completed_at' => null,
     4326                ];
     4327                if ($blocked === 0) {
     4328                    $updates['last_error'] = null;
     4329                }
     4330                $origin_state_result = $this->update_view_source_origin_state($source_id, $origin_key, $updates, $post_origin_lock);
     4331                if (!in_array($origin_state_result['status'] ?? '', ['updated'], true)) {
     4332                    // Do not treat a failed/ambiguous state transition as a
     4333                    // successful retry finalization. Keep recovery scheduled.
     4334                    $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key);
     4335                    return [
     4336                        'status' => 'origin_state_recovery_failed',
     4337                        'origin_state' => $origin_state_result,
     4338                        'result' => $result,
     4339                        'remaining' => $remaining,
     4340                    ];
     4341                }
     4342                $this->schedule_generic_historical_view_import_batch(5, $source_id, $origin_key);
     4343            } elseif ($blocked > 0 && $origin_status !== 'needs_attention') {
     4344                // Only a non-progressing, nonterminal origin may be finalized here.
     4345                // Never downgrade an already-finalized attention state.
     4346                $origin_state_result = $this->update_view_source_origin_state($source_id, $origin_key, [
     4347                    'status' => 'needs_attention',
     4348                    'last_error' => 'Some historical records could not be recovered after three total attempts.',
     4349                ], $post_origin_lock);
     4350                if (!in_array($origin_state_result['status'] ?? '', ['updated'], true)) {
     4351                    $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key);
     4352                    return [
     4353                        'status' => 'origin_state_recovery_failed',
     4354                        'origin_state' => $origin_state_result,
     4355                        'result' => $result,
     4356                        'remaining' => $remaining,
     4357                    ];
     4358                }
     4359            }
     4360        }
     4361
     4362        // Ledger reconciliation may itself encounter a reconnect after the
     4363        // post-processing lock was acquired. Never aggregate source status unless
     4364        // this request still owns that exact MySQL lock session.
     4365        if ($this->generic_advisory_lock_session_is_current($post_origin_lock)) {
     4366            $this->refresh_generic_historical_source_status($source_id);
     4367        } else {
     4368            $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key);
     4369            return ['status' => 'post_processing_session_lost', 'result' => $result, 'remaining' => $remaining];
     4370        }
     4371        return ['status' => 'retry_processed', 'result' => $result, 'remaining' => $remaining];
     4372        } finally {
     4373            $this->release_generic_advisory_lock($post_origin_lock, 'retry_post_processing');
     4374        }
     4375    }
     4376
     4377    /** Return a discovered adapter instance for later snapshot/import workers. */
     4378    private function get_historical_view_adapter($adapter_key) {
     4379        $adapter_key = sanitize_key((string) $adapter_key);
     4380        return isset($this->historical_view_adapters[$adapter_key])
     4381            ? $this->historical_view_adapters[$adapter_key]
     4382            : null;
     4383    }
     4384
     4385    /** Schedule the generic historical importer without overlapping scheduled runs. */
     4386    private function schedule_generic_historical_view_import_batch($delay = 10, $source_id = 0, $origin_key = '') {
     4387        $args = [(int) $source_id, (string) $origin_key];
     4388        $hook = 'statpl_generic_view_import_batch';
     4389        $requested_timestamp = time() + max(1, (int) $delay);
     4390
     4391        if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) {
     4392            $existing_timestamp = false;
     4393            if (function_exists('as_next_scheduled_action')) {
     4394                try {
     4395                    $existing_timestamp = as_next_scheduled_action($hook, $args, 'statpl-plus');
     4396                } catch (Throwable $exception) {
     4397                    $this->log_generic_historical_scheduler_error($hook . '_lookup', $exception->getMessage());
     4398                }
     4399            }
     4400            if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) {
     4401                return true;
     4402            }
     4403            if ($existing_timestamp && function_exists('as_unschedule_all_actions')) {
     4404                try {
     4405                    as_unschedule_all_actions($hook, $args, 'statpl-plus');
     4406                } catch (Throwable $exception) {
     4407                    $this->log_generic_historical_scheduler_error($hook . '_unschedule', $exception->getMessage());
     4408                    return true;
     4409                }
     4410            } elseif ($existing_timestamp) {
     4411                return true;
     4412            } elseif (function_exists('as_has_scheduled_action')) {
     4413                try {
     4414                    if (as_has_scheduled_action($hook, $args, 'statpl-plus')) {
     4415                        // Without reliable timing/removal APIs, retain the existing action.
     4416                        return true;
     4417                    }
     4418                } catch (Throwable $exception) {
     4419                    $this->log_generic_historical_scheduler_error($hook . '_presence_check', $exception->getMessage());
     4420                }
     4421            }
     4422            try {
     4423                $action_id = as_schedule_single_action($requested_timestamp, $hook, $args, 'statpl-plus');
     4424                if ($action_id) {
     4425                    return true;
     4426                }
     4427            } catch (Throwable $exception) {
     4428                $this->log_generic_historical_scheduler_error($hook, $exception->getMessage());
     4429            }
     4430            // Fall through to WP-Cron if Action Scheduler did not accept the action.
     4431        }
     4432
     4433        try {
     4434            $existing_timestamp = wp_next_scheduled($hook, $args);
     4435            if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) {
     4436                return true;
     4437            }
     4438            if ($existing_timestamp) {
     4439                wp_unschedule_event((int) $existing_timestamp, $hook, $args);
     4440            }
     4441            $scheduled = wp_schedule_single_event($requested_timestamp, $hook, $args);
     4442            if (!$scheduled || is_wp_error($scheduled)) {
     4443                $this->log_generic_historical_scheduler_error($hook, is_wp_error($scheduled) ? $scheduled->get_error_message() : 'WP-Cron rejected the event.');
     4444                return false;
     4445            }
     4446            return true;
     4447        } catch (Throwable $exception) {
     4448            $this->log_generic_historical_scheduler_error($hook, $exception->getMessage());
     4449            return false;
     4450        }
     4451    }
     4452
     4453    /** Persist a generic-source failure with bounded exponential retry metadata. */
     4454    private function record_generic_historical_view_failure($source_id, $origin_key, $record_key, $product_id, $error, $retry_checkpoint = null, $max_attempts = 8, $lock_context = null) {
     4455        global $wpdb;
     4456
     4457        $source_id = absint($source_id);
     4458        if (!$this->generic_advisory_lock_session_is_current($lock_context)) {
     4459            return ['attempts' => 0, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'lock_session_lost'];
     4460        }
     4461        $record_key = substr((string) $record_key, 0, 191);
     4462        if ($record_key === '') {
     4463            $record_key = 'batch:' . substr(hash('sha256', (string) $origin_key), 0, 32);
     4464        }
     4465        $record_hash = hash('sha256', (string) $origin_key . "\0" . $record_key);
     4466        $existing = statpl_db_get_row(statpl_db_prepare(
     4467            "SELECT id, attempts, status, next_retry_at, retry_checkpoint, last_error FROM {$this->view_source_failures_table} WHERE source_id = %d AND source_record_hash = %s LIMIT 1",
     4468            $source_id,
     4469            $record_hash
     4470        ), ARRAY_A);
     4471        if (!empty($wpdb->last_error)) {
     4472            $this->log_generic_historical_scheduler_error('failure_ledger_lookup', $wpdb->last_error);
     4473            return ['attempts' => 0, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'ledger_lookup_failed'];
     4474        }
     4475        if (!$this->generic_advisory_lock_session_is_current($lock_context)) {
     4476            return ['attempts' => 0, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'lock_session_lost'];
     4477        }
     4478        $now = current_time('mysql');
     4479        if ($existing && isset($existing['status']) && $existing['status'] === 'blocked') {
     4480            // The ledger row may have been committed even if the corresponding
     4481            // origin-state update failed on the previous pass. Repair that
     4482            // derived state on every subsequent locked observation without
     4483            // incrementing attempts or rewriting the durable failure record.
     4484            $existing_origin_status = empty($existing['retry_checkpoint']) ? 'needs_attention' : 'retry_wait';
     4485            $origin_state_saved = $this->update_view_source_origin_state($source_id, $origin_key, [
     4486                'last_error' => substr((string) ($existing['last_error'] ?? ''), 0, 2000),
     4487                'status' => $existing_origin_status,
     4488            ], $lock_context);
     4489            if (empty($origin_state_saved['status']) || $origin_state_saved['status'] !== 'updated') {
     4490                $this->log_generic_historical_scheduler_error('failure_origin_state_recovery', $origin_state_saved['error'] ?? ($origin_state_saved['status'] ?? 'Unable to recover origin state from the existing blocked failure record.'));
     4491                $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     4492                return ['attempts' => (int) $existing['attempts'], 'blocked' => true, 'persisted' => true, 'retry_delay' => 0, 'status' => 'origin_state_recovery_failed'];
     4493            }
     4494            return ['attempts' => (int) $existing['attempts'], 'blocked' => true, 'persisted' => true, 'retry_delay' => 0, 'status' => 'already_blocked'];
     4495        }
     4496        if ($existing && !empty($existing['next_retry_at']) && $existing['next_retry_at'] > $now) {
     4497            $retry_at = strtotime((string) $existing['next_retry_at']);
     4498            $now_at = strtotime((string) $now);
     4499            $remaining = ($retry_at !== false && $now_at !== false) ? max(1, $retry_at - $now_at) : 60;
     4500            $origin_state_saved = $this->update_view_source_origin_state($source_id, $origin_key, [
     4501                'last_error' => substr((string) ($existing['last_error'] ?? ''), 0, 2000),
     4502                'status' => 'retry_wait',
     4503            ], $lock_context);
     4504            if (empty($origin_state_saved['status']) || $origin_state_saved['status'] !== 'updated') {
     4505                $this->log_generic_historical_scheduler_error('failure_origin_state_recovery', $origin_state_saved['error'] ?? ($origin_state_saved['status'] ?? 'Unable to recover origin state from the existing retry failure record.'));
     4506                $this->schedule_generic_historical_failure_retry(min(86400, $remaining), $source_id, $origin_key);
     4507                return ['attempts' => (int) $existing['attempts'], 'blocked' => true, 'persisted' => true, 'retry_delay' => 0, 'status' => 'origin_state_recovery_failed'];
     4508            }
     4509            return ['attempts' => (int) $existing['attempts'], 'blocked' => false, 'persisted' => true, 'retry_delay' => min(86400, $remaining), 'status' => 'retry_already_scheduled'];
     4510        }
     4511
     4512        $attempts = $existing ? ((int) $existing['attempts'] + 1) : 1;
     4513        $max_attempts = max(1, absint($max_attempts));
     4514        $blocked = $attempts >= $max_attempts;
     4515        $delay = min(86400, 60 * (2 ** min(10, max(0, $attempts - 1))));
     4516        $next_retry = $blocked ? null : current_datetime()->modify('+' . $delay . ' seconds')->format('Y-m-d H:i:s');
     4517        $data = [
     4518            'source_id' => $source_id,
     4519            'origin_key' => sanitize_key((string) $origin_key),
     4520            'source_record_key' => $record_key,
     4521            'source_record_hash' => $record_hash,
     4522            'product_id' => absint($product_id),
     4523            'attempts' => $attempts,
     4524            'last_error' => substr(sanitize_textarea_field((string) $error), 0, 60000),
     4525            'status' => $blocked ? 'blocked' : 'retry',
     4526            'next_retry_at' => $next_retry,
     4527            'retry_checkpoint' => is_array($retry_checkpoint) ? wp_json_encode($retry_checkpoint) : null,
     4528            'updated_at' => $now,
     4529        ];
     4530        $allowed_columns = [
     4531            'source_id', 'origin_key', 'source_record_key', 'source_record_hash',
     4532            'product_id', 'attempts', 'last_error', 'status', 'next_retry_at',
     4533            'retry_checkpoint', 'updated_at',
     4534        ];
     4535        $columns = array_keys($data);
     4536        if (array_diff($columns, $allowed_columns) || array_diff($allowed_columns, $columns)) {
     4537            $this->log_generic_historical_scheduler_error('failure_ledger_schema', 'Failure ledger data did not match the fixed column allowlist.');
     4538            return ['attempts' => $attempts, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'ledger_schema_mismatch'];
     4539        }
     4540        $placeholders = [];
     4541        $values = [];
     4542        foreach ($allowed_columns as $column) {
     4543            $value = $data[$column];
     4544            $placeholders[] = is_int($value) ? '%d' : '%s';
     4545            $values[] = $value;
     4546        }
     4547        $lock_name = (string) ($lock_context['lock_name'] ?? '');
     4548        if ($lock_name === '' || !$this->generic_advisory_lock_session_is_current($lock_context)) {
     4549            return ['attempts' => $attempts, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'lock_session_lost'];
     4550        }
     4551
     4552        if ($existing) {
     4553            // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The SET placeholders are generated from the fixed allowlist; update_values is generated from the same allowlist with the same immutable key columns excluded, followed by id and lock_name.
     4554            $set = [];
     4555            foreach ($allowed_columns as $column) {
     4556                if (in_array($column, ['source_id', 'source_record_hash'], true)) {
     4557                    continue;
     4558                }
     4559                $value = $data[$column];
     4560                $set[] = "`{$column}` = " . (is_int($value) ? '%d' : '%s');
     4561            }
     4562            $update_values = [];
     4563            foreach ($allowed_columns as $column) {
     4564                if (!in_array($column, ['source_id', 'source_record_hash'], true)) {
     4565                    $update_values[] = $data[$column];
     4566                }
     4567            }
     4568            // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The SET placeholder list and update_values are built in the same pass over the fixed 11-column allowlist, excluding the same two immutable key columns; the two trailing placeholders are always id and lock_name.
     4569// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     4570            $query = /* phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Placeholder fragments and their values are assembled together from the same fixed allowlists; optional lock predicates and matching values are added together. */ statpl_db_prepare(
     4571                "UPDATE {$this->view_source_failures_table} SET " . implode(', ', /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $set) . " WHERE id = %d AND IS_USED_LOCK(%s) = CONNECTION_ID()",
     4572                array_merge($update_values, [(int) $existing['id'], $lock_name])
     4573            );
     4574        } else {
     4575// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     4576            $query = statpl_db_prepare(
     4577                "INSERT INTO {$this->view_source_failures_table} (" . implode(', ', /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ array_map(function ($column) { return "`{$column}`"; }, $columns)) . ") SELECT " . implode(', ', $placeholders) . " WHERE IS_USED_LOCK(%s) = CONNECTION_ID()",
     4578                array_merge($values, [$lock_name])
     4579            );
     4580        }
     4581        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $query is assigned exclusively from statpl_db_prepare() in both branches; dynamic identifiers and column names come from fixed allowlists, and all data values use placeholders.
     4582// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     4583        $persisted = statpl_db_query($query);
     4584        if ($persisted === false || $wpdb->last_error !== '') {
     4585            return ['attempts' => $attempts, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'ledger_write_failed', 'error' => $wpdb->last_error];
     4586        }
     4587        if ($persisted !== 1) {
     4588            return ['attempts' => $attempts, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'lock_session_lost'];
     4589        }
     4590        if (strpos($record_key, 'batch:') !== 0 && $record_key !== 'snapshot_initialization') {
     4591            $this->save_generic_historical_report_record($source_id, $origin_key, $record_hash, $blocked ? 'failed' : 'retry', $attempts, 'record_import_failed');
     4592        }
     4593
     4594        $origin_status = ($blocked && !is_array($retry_checkpoint)) ? 'needs_attention' : 'retry_wait';
     4595        $origin_state_saved = $this->update_view_source_origin_state($source_id, $origin_key, [
     4596            'last_error' => substr((string) $error, 0, 2000),
     4597            'status' => $origin_status,
     4598        ], $lock_context);
     4599        if (empty($origin_state_saved['status']) || !in_array($origin_state_saved['status'], ['updated'], true)) {
     4600            $this->log_generic_historical_scheduler_error('failure_origin_state', $origin_state_saved['error'] ?? ($origin_state_saved['status'] ?? 'Unable to update origin state after recording a failure.'));
     4601            // The failure row is durable, but its derived origin status may be
     4602            // stale. Ensure another locked pass is scheduled to repair the
     4603            // origin state; do not rely on callers, which correctly stop when
     4604            // this operation reports blocked=true.
     4605            if (is_array($retry_checkpoint)) {
     4606                $this->schedule_generic_historical_failure_retry(max(60, $delay), $source_id, $origin_key);
     4607            } else {
     4608                $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     4609            }
     4610            return ['attempts' => $attempts, 'blocked' => true, 'persisted' => true, 'retry_delay' => 0, 'status' => 'origin_state_update_failed'];
     4611        }
     4612
     4613        return ['attempts' => $attempts, 'blocked' => $blocked, 'persisted' => true, 'retry_delay' => $blocked ? 0 : $delay];
     4614    }
     4615
     4616    /** Clear a previously recorded failure after its record is safely handled. */
     4617    private function clear_generic_historical_view_failure($source_id, $origin_key, $record_key, $lock_context = null, $report_outcome = 'imported') {
     4618        global $wpdb;
     4619        if (!$this->generic_advisory_lock_session_is_current($lock_context)) {
     4620            return false;
     4621        }
     4622        $record_key = substr((string) $record_key, 0, 191);
     4623        if ($record_key === '') {
     4624            return false;
     4625        }
     4626        $record_hash = hash('sha256', (string) $origin_key . "\0" . $record_key);
     4627        $existing_failure = statpl_db_get_row(statpl_db_prepare(
     4628            "SELECT attempts FROM {$this->view_source_failures_table} WHERE source_id = %d AND source_record_hash = %s LIMIT 1",
     4629            absint($source_id), $record_hash
     4630        ), ARRAY_A);
     4631        if (!empty($wpdb->last_error)) { return false; }
     4632        if ($report_outcome === 'imported' && strpos($record_key, 'batch:') !== 0 && $record_key !== 'snapshot_initialization') {
     4633            $attempts = is_array($existing_failure) ? (int) ($existing_failure['attempts'] ?? 0) : 0;
     4634            if (!$this->save_generic_historical_report_record($source_id, $origin_key, $record_hash, 'imported', $attempts, '')) { return false; }
     4635        }
     4636        $lock_name = (string) ($lock_context['lock_name'] ?? '');
     4637        if ($lock_name === '' || !$this->generic_advisory_lock_session_is_current($lock_context)) {
     4638            return false;
     4639        }
     4640        $deleted = statpl_db_query(statpl_db_prepare(
     4641            "DELETE FROM {$this->view_source_failures_table} WHERE source_id = %d AND source_record_hash = %s AND IS_USED_LOCK(%s) = CONNECTION_ID()",
     4642            absint($source_id),
     4643            $record_hash,
     4644            $lock_name
     4645        ));
     4646        if ($deleted === false || $wpdb->last_error !== '') {
     4647            $this->log_generic_historical_scheduler_error('failure_ledger_clear', $wpdb->last_error ?: 'Unable to clear a resolved failure record.');
     4648            return false;
     4649        }
     4650        // A zero-row DELETE is ambiguous: the failure row may already be absent,
     4651        // or the SQL ownership predicate may have rejected the operation after a
     4652        // reconnect/lock loss. Only treat the former as successful, and confirm
     4653        // that this worker still owns the original lock session after the query.
     4654        if (!$this->generic_advisory_lock_session_is_current($lock_context)) {
     4655            return false;
     4656        }
     4657        return true;
     4658    }
     4659
     4660    /**
     4661     * Process one generic source-origin batch. Each accepted record is committed
     4662     * atomically with its source event and product total; the checkpoint advances
     4663     * only after the record is safely applied or recognized as an exact duplicate.
     4664     */
     4665    public function run_generic_historical_view_import_batch($source_id = 0, $origin_key = '') {
     4666        global $wpdb;
     4667
     4668        $source_id = absint($source_id);
     4669        $origin_key = sanitize_key((string) $origin_key);
     4670        if (!$source_id || $origin_key === '') {
     4671            return ['status' => 'invalid_input'];
     4672        }
     4673
     4674        // A connection-scoped MySQL advisory lock prevents two cron/Action
     4675        // Scheduler workers from advancing the same source-origin checkpoint.
     4676        // The lock is released automatically if PHP or the DB connection dies.
     4677        $lock_name = 'statpl_generic_' . substr(hash('sha256', $wpdb->prefix . ':' . $source_id . ':' . $origin_key), 0, 40);
     4678        $origin_lock = $this->acquire_generic_advisory_lock($lock_name);
     4679        if (empty($origin_lock['acquired'])) {
     4680            $this->schedule_generic_historical_view_import_batch(30, $source_id, $origin_key);
     4681            return ['status' => 'worker_already_running'];
     4682        }
     4683
     4684        try {
     4685            if (!$this->generic_advisory_lock_session_is_current($origin_lock)) {
     4686                $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     4687                return ['status' => 'origin_lock_session_lost'];
     4688            }
     4689            $result = $this->process_generic_historical_view_import_batch($source_id, $origin_key, null, $origin_lock);
     4690            // The processing method can return after detecting a lost MySQL session.
     4691            // Do not start a new source-status write on a transparently reconnected
     4692            // session: it would run outside the origin lock that serialized this worker.
     4693            if ($this->generic_advisory_lock_session_is_current($origin_lock)) {
     4694                $this->refresh_generic_historical_source_status($source_id);
     4695            } else {
     4696                $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     4697            }
     4698            return $result;
     4699        } finally {
     4700            $this->release_generic_advisory_lock($origin_lock, 'main_importer');
     4701        }
     4702    }
     4703
     4704    /** Validate a normalized source datetime without allowing silent MySQL coercion. */
     4705    private function is_valid_generic_historical_datetime($value) {
     4706        if ($value === null || $value === '') {
     4707            return true;
     4708        }
     4709        if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $value)) {
     4710            return false;
     4711        }
     4712        $date = DateTime::createFromFormat('!Y-m-d H:i:s', $value);
     4713        $errors = DateTime::getLastErrors();
     4714        return $date instanceof DateTime
     4715            && ($errors === false || (empty($errors['warning_count']) && empty($errors['error_count'])))
     4716            && $date->format('Y-m-d H:i:s') === $value;
     4717    }
     4718
     4719    /** Accept only a JSON-safe scalar/array cursor that can be persisted and resumed. */
     4720    private function is_valid_generic_historical_cursor($cursor) {
     4721        if ($cursor === null || is_resource($cursor) || is_object($cursor)) {
     4722            return false;
     4723        }
     4724        $encoded = wp_json_encode($cursor);
     4725        return is_string($encoded) && strlen($encoded) <= 60000;
     4726    }
     4727
     4728    /**
     4729     * Discover overlap candidates fairly across source IDs. A global LIMIT can let
     4730     * a high-volume source crowd every other adapter out of the reconciliation set.
     4731     * The per-source fetch is bounded, then candidates are interleaved round-robin.
     4732     */
     4733    private function get_generic_reconciliation_candidates_by_source($mode, $criteria, $limit = 25) {
     4734        global $wpdb;
     4735
     4736        if (!is_array($criteria)) {
     4737            return ['status' => 'error', 'error' => 'Invalid reconciliation criteria.', 'events' => [], 'observed' => 0, 'truncated' => false];
     4738        }
     4739        if ($mode === 'fingerprint') {
     4740            $fingerprint = isset($criteria['fingerprint']) ? (string) $criteria['fingerprint'] : '';
     4741            $source_id = isset($criteria['source_id']) ? absint($criteria['source_id']) : 0;
     4742            if ($fingerprint === '' || !$source_id) {
     4743                return ['status' => 'error', 'error' => 'Invalid fingerprint reconciliation criteria.', 'events' => [], 'observed' => 0, 'truncated' => false];
     4744            }
     4745            $where_sql = 'events.source_fingerprint = %s AND events.source_id <> %d';
     4746            $where_args = [$fingerprint, $source_id];
     4747        } elseif ($mode === 'temporal') {
     4748            $source_id = isset($criteria['source_id']) ? absint($criteria['source_id']) : 0;
     4749            $product_id = isset($criteria['product_id']) ? absint($criteria['product_id']) : 0;
     4750            $period_start = isset($criteria['period_start']) ? (string) $criteria['period_start'] : '';
     4751            $period_end = isset($criteria['period_end']) ? (string) $criteria['period_end'] : '';
     4752            if (!$source_id || !$product_id || $period_start === '' || $period_end === '') {
     4753                return ['status' => 'error', 'error' => 'Invalid temporal reconciliation criteria.', 'events' => [], 'observed' => 0, 'truncated' => false];
     4754            }
     4755            $where_sql = 'events.source_id <> %d AND events.product_id = %d AND events.period_start <= %s AND events.period_end >= %s';
     4756            $where_args = [$source_id, $product_id, $period_end, $period_start];
     4757        } else {
     4758            return ['status' => 'error', 'error' => 'Unsupported reconciliation mode.', 'events' => [], 'observed' => 0, 'truncated' => false];
     4759        }
     4760        $limit = max(1, min(25, absint($limit)));
     4761        $reconciliation_sql = "SELECT DISTINCT events.source_id
     4762             FROM {$this->view_source_events_table} AS events
     4763             WHERE {$where_sql}
     4764             ORDER BY events.source_id ASC";
     4765        // The criteria branches above define either four temporal placeholders
     4766        // or two exact-match placeholders; prepare receives the matching values.
     4767        if ($mode === 'temporal') {
     4768// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     4769            $source_ids = statpl_db_get_col(statpl_db_prepare(
     4770                /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $reconciliation_sql,
     4771                $source_id,
     4772                $product_id,
     4773                $period_end,
     4774                $period_start
     4775            ));
     4776        } else {
     4777// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     4778            $source_ids = statpl_db_get_col(statpl_db_prepare(
     4779                /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $reconciliation_sql,
     4780                $fingerprint,
     4781                $source_id
     4782            ));
     4783        }
     4784        if (!empty($wpdb->last_error)) {
     4785            return ['status' => 'error', 'error' => (string) $wpdb->last_error, 'events' => [], 'observed' => 0, 'truncated' => false];
     4786        }
     4787
     4788        $per_source = [];
     4789        $observed = 0;
     4790        $truncated = false;
     4791        foreach ((array) $source_ids as $candidate_source_id) {
     4792            $candidate_source_id = absint($candidate_source_id);
     4793            if (!$candidate_source_id) {
     4794                continue;
     4795            }
     4796            $rows = statpl_db_get_results(statpl_db_prepare(
     4797                "SELECT events.id, events.source_id, events.source_record_key,
     4798                        sources.source_key AS candidate_source_key,
     4799                        sources.adapter_key AS candidate_adapter_key, events.product_id,
     4800                        events.record_type, events.observed_views, events.period_start,
     4801                        events.period_end, events.applied_views
     4802                 FROM {$this->view_source_events_table} AS events
     4803                 LEFT JOIN {$this->view_sources_table} AS sources ON sources.id = events.source_id
     4804                 WHERE ({$where_sql}) AND events.source_id = %d
     4805                 ORDER BY events.period_start ASC, events.period_end ASC, events.id ASC
     4806                 LIMIT 26 FOR UPDATE",
     4807                array_merge($where_args, [$candidate_source_id])
     4808            ), ARRAY_A);
     4809            if (!empty($wpdb->last_error)) {
     4810                return ['status' => 'error', 'error' => (string) $wpdb->last_error, 'events' => [], 'observed' => $observed, 'truncated' => $truncated];
     4811            }
     4812            if (count($rows) > 25) {
     4813                $truncated = true;
     4814                $rows = array_slice($rows, 0, 25);
     4815            }
     4816            $observed += count($rows);
     4817            if ($rows) {
     4818                $per_source[$candidate_source_id] = array_values($rows);
     4819            }
     4820        }
     4821
     4822        $events = [];
     4823        $round = 0;
     4824        do {
     4825            $added_this_round = false;
     4826            foreach ($per_source as $source_rows) {
     4827                if (isset($source_rows[$round])) {
     4828                    $events[] = $source_rows[$round];
     4829                    $added_this_round = true;
     4830                    if (count($events) > $limit) {
     4831                        $truncated = true;
     4832                        break 2;
     4833                    }
     4834                }
     4835            }
     4836            $round++;
     4837        } while ($added_this_round);
     4838
     4839        if ($observed > count($events)) {
     4840            $truncated = true;
     4841        }
     4842        return [
     4843            'status' => 'ok',
     4844            'events' => array_slice($events, 0, $limit),
     4845            'observed' => $observed,
     4846            'truncated' => $truncated,
     4847        ];
     4848    }
     4849
     4850    /**
     4851     * Build a source-wide, deterministic overlap profile for the current record.
     4852     * This is deliberately descriptive: timestamps and aggregate periods are not
     4853     * proof that two providers observed the same pageview. The profile gives the
     4854     * later reconciliation pass comparable signals without changing applied_views.
     4855     */
     4856    /**
     4857     * Return declared source capabilities for reconciliation review.
     4858     * This is a descriptive capability inventory, not a numeric reliability score.
     4859     * Missing declarations remain unknown; they are never inferred from plugin names.
     4860     */
     4861    private function get_generic_source_capability_profiles($source_ids) {
     4862        global $wpdb;
     4863
     4864        $source_ids = array_values(array_unique(array_filter(array_map('absint', (array) $source_ids))));
     4865        if (!$source_ids) {
     4866            return ['status' => 'empty', 'sources' => [], 'requested_source_count' => 0];
     4867        }
     4868
     4869        // The list is built only from normalized positive integer source IDs.
     4870        // A fixed-size batch limit keeps the SQL placeholder count bounded.
     4871        $placeholders = implode(',', array_fill(0, count($source_ids), '%d'));
     4872        // Start from the source registry and LEFT JOIN origins. A persisted source
     4873        // with no origin row must remain visible as an unknown-capability source;
     4874        // an INNER JOIN would silently omit it from the reconciliation inventory.
     4875        $inventory_sql = "SELECT s.id AS source_id, s.source_key, s.display_name,
     4876                    s.adapter_key AS source_adapter_key, s.granularity AS source_granularity,
     4877                    o.origin_key, o.adapter_key, o.capabilities,
     4878                    o.detection_status
     4879             FROM {$this->view_sources_table} AS s
     4880             LEFT JOIN {$this->view_source_origins_table} AS o ON o.source_id = s.id
     4881             WHERE s.id IN ({$placeholders})
     4882             ORDER BY s.id ASC, o.origin_key ASC";
     4883        // prepare() accepts an argument array as its second parameter. This keeps
     4884        // the dynamically sized placeholder list paired with its normalized IDs.
     4885// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     4886        $rows = statpl_db_get_results(statpl_db_prepare($inventory_sql, $source_ids), ARRAY_A);
     4887        if (!empty($wpdb->last_error) || !is_array($rows)) {
     4888            return [
     4889                'status' => 'query_failed',
     4890                'sources' => [],
     4891                'requested_source_count' => count($source_ids),
     4892                'error' => (string) $wpdb->last_error,
     4893            ];
     4894        }
     4895
     4896        $profiles = [];
     4897        foreach ($rows as $row) {
     4898            $id = absint($row['source_id'] ?? 0);
     4899            if (!$id) { continue; }
     4900            $profiles[$id] = $profiles[$id] ?? [
     4901                'source_id' => $id,
     4902                'source_key' => sanitize_key((string) ($row['source_key'] ?? '')),
     4903                'display_name' => sanitize_text_field((string) ($row['display_name'] ?? '')),
     4904                'source_adapter_key' => sanitize_key((string) ($row['source_adapter_key'] ?? '')),
     4905                'source_granularity' => sanitize_key((string) ($row['source_granularity'] ?? 'unknown')),
     4906                'origin_profile_status' => 'available',
     4907                'origins' => [],
     4908            ];
     4909
     4910            // A NULL origin row is meaningful: the source exists, but no declared
     4911            // origin capabilities are available. Do not infer them from its name.
     4912            if ($row['origin_key'] === null || $row['origin_key'] === '') {
     4913                $profiles[$id]['origin_profile_status'] = 'no_origin_declarations';
     4914                continue;
     4915            }
     4916
     4917            $declared = json_decode((string) ($row['capabilities'] ?? ''), true);
     4918            if (!is_array($declared)) { $declared = []; }
     4919
     4920            $granularity = sanitize_key((string) ($declared['granularity'] ?? 'unknown'));
     4921            $precision = sanitize_key((string) ($declared['timestamp_precision'] ?? 'unknown'));
     4922            $storage_granularity = sanitize_key((string) ($declared['source_storage_granularity'] ?? 'unknown'));
     4923            $profiles[$id]['origins'][] = [
     4924                'origin_key' => sanitize_key((string) ($row['origin_key'] ?? '')),
     4925                'adapter_key' => sanitize_key((string) ($row['adapter_key'] ?? '')),
     4926                'detection_status' => sanitize_key((string) ($row['detection_status'] ?? 'unknown')),
     4927                'granularity' => $granularity,
     4928                'timestamp_precision' => $precision,
     4929                'source_storage_granularity' => $storage_granularity,
     4930                'product_id_direct' => array_key_exists('product_id_direct', $declared) ? (bool) $declared['product_id_direct'] : null,
     4931                'target_requires_product_resolution' => array_key_exists('target_requires_product_resolution', $declared) ? (bool) $declared['target_requires_product_resolution'] : null,
     4932                'stable_event_identity_declared' => array_key_exists('stable_event_identity', $declared) ? (bool) $declared['stable_event_identity'] : null,
     4933                'bot_filtered' => array_key_exists('bot_filtered', $declared) ? (bool) $declared['bot_filtered'] : null,
     4934                'immutable_past_days' => array_key_exists('immutable_past_days', $declared) ? (bool) $declared['immutable_past_days'] : null,
     4935                'capability_comparability' => [
     4936                    'event_level' => $granularity === 'event',
     4937                    'second_precision' => $precision === 'seconds',
     4938                    'direct_product_mapping' => !empty($declared['product_id_direct']),
     4939                    'aggregate_only_or_unknown' => in_array($granularity, ['daily_aggregate', 'hourly_aggregate', 'unknown'], true),
     4940                ],
     4941            ];
     4942        }
     4943
     4944        // Preserve requested IDs that have no matching source-registry row as
     4945        // explicit unresolved entries instead of silently shrinking the scope.
     4946        foreach ($source_ids as $requested_id) {
     4947            if (!isset($profiles[$requested_id])) {
     4948                $profiles[$requested_id] = [
     4949                    'source_id' => $requested_id,
     4950                    'source_key' => '',
     4951                    'display_name' => '',
     4952                    'source_adapter_key' => '',
     4953                    'source_granularity' => 'unknown',
     4954                    'origin_profile_status' => 'source_registry_row_missing',
     4955                    'origins' => [],
     4956                ];
     4957            }
     4958        }
     4959        ksort($profiles, SORT_NUMERIC);
     4960
     4961        $resolved_count = 0;
     4962        $without_origin_count = 0;
     4963        foreach ($profiles as $profile) {
     4964            if (($profile['origin_profile_status'] ?? '') === 'source_registry_row_missing') {
     4965                continue;
     4966            }
     4967            $resolved_count++;
     4968            if (($profile['origin_profile_status'] ?? '') === 'no_origin_declarations') {
     4969                $without_origin_count++;
     4970            }
     4971        }
     4972        $status = $resolved_count < count($source_ids)
     4973            ? 'partial'
     4974            : ($without_origin_count > 0 ? 'partial' : 'available');
     4975
     4976        return [
     4977            'status' => $status,
     4978            'sources' => array_values($profiles),
     4979            'requested_source_count' => count($source_ids),
     4980            'resolved_source_count' => $resolved_count,
     4981            'sources_without_origin_declarations' => $without_origin_count,
     4982        ];
     4983    }
     4984
     4985    private function build_generic_cross_source_overlap_assessment($product_id, $source_id, $adapter_key, $record_type, $period_start, $period_end, $candidates, $truncated = false, $observed_views = 0) {
     4986        $product_id = absint($product_id);
     4987        $record_type = sanitize_key((string) $record_type);
     4988        $period_start = $period_start !== null ? (string) $period_start : '';
     4989        $period_end = $period_end !== null ? (string) $period_end : '';
     4990        $sources = [];
     4991        $same_instant_event_candidates = 0;
     4992        $same_bounds_aggregate_candidates = 0;
     4993        $intersecting_aggregate_candidates = 0;
     4994        $different_type_candidates = 0;
     4995
     4996        foreach ((array) $candidates as $candidate) {
     4997            if (!is_array($candidate) || (int) ($candidate['product_id'] ?? 0) !== $product_id) {
     4998                continue;
     4999            }
     5000            $candidate_source_id = absint($candidate['source_id'] ?? 0);
     5001            if (!$candidate_source_id) {
     5002                continue;
     5003            }
     5004            $candidate_type = sanitize_key((string) ($candidate['record_type'] ?? ''));
     5005            $candidate_start = isset($candidate['period_start']) ? (string) $candidate['period_start'] : '';
     5006            $candidate_end = isset($candidate['period_end']) ? (string) $candidate['period_end'] : '';
     5007            $same_bounds = $period_start !== '' && $period_end !== ''
     5008                && $candidate_start === $period_start && $candidate_end === $period_end;
     5009            $intersects = $period_start !== '' && $period_end !== ''
     5010                && $candidate_start !== '' && $candidate_end !== ''
     5011                && $candidate_start <= $period_end && $candidate_end >= $period_start;
     5012
     5013            if (!isset($sources[$candidate_source_id])) {
     5014                $sources[$candidate_source_id] = [
     5015                    'source_id' => $candidate_source_id,
     5016                    'source_key' => isset($candidate['candidate_source_key']) ? (string) $candidate['candidate_source_key'] : null,
     5017                    'adapter_key' => isset($candidate['candidate_adapter_key']) ? (string) $candidate['candidate_adapter_key'] : null,
     5018                    'record_types' => [],
     5019                    'candidate_records' => 0,
     5020                ];
     5021            }
     5022            $sources[$candidate_source_id]['candidate_records']++;
     5023            if ($candidate_type !== '') {
     5024                $sources[$candidate_source_id]['record_types'][$candidate_type] = true;
     5025            }
     5026
     5027            if ($candidate_type !== $record_type) {
     5028                $different_type_candidates++;
     5029            }
     5030            if ($record_type === 'event' && $candidate_type === 'event' && $same_bounds) {
     5031                $same_instant_event_candidates++;
     5032            } elseif ($record_type === 'aggregate' && $candidate_type === 'aggregate' && $same_bounds) {
     5033                $same_bounds_aggregate_candidates++;
     5034            } elseif ($intersects && ($record_type === 'aggregate' || $candidate_type === 'aggregate')) {
     5035                $intersecting_aggregate_candidates++;
     5036            }
     5037        }
     5038
     5039        foreach ($sources as &$source) {
     5040            $source['record_types'] = array_keys($source['record_types']);
     5041            sort($source['record_types'], SORT_STRING);
     5042        }
     5043        unset($source);
     5044        ksort($sources, SORT_NUMERIC);
     5045
     5046        // Identical naive datetime labels cannot establish a shared temporal
     5047        // bucket without persisted source-timezone provenance; no count range is inferred.
     5048        $same_bucket_range = [
     5049            'status' => 'unavailable',
     5050            'reason' => 'timezone_basis_not_persisted',
     5051            'minimum_unique_views' => null,
     5052            'maximum_unique_views' => null,
     5053            'minimum_possible_duplicate_views' => null,
     5054            'maximum_possible_duplicate_views' => null,
     5055            'source_contributions' => [],
     5056        ];
     5057
     5058        $capability_source_ids = array_merge([absint($source_id)], array_keys($sources));
     5059        $capability_profiles = $this->get_generic_source_capability_profiles($capability_source_ids);
     5060
     5061        return [
     5062            'version' => '0.2.0',
     5063            'scope' => 'same_product_different_sources',
     5064            'incoming_source' => [
     5065                'source_id' => absint($source_id),
     5066                'adapter_key' => sanitize_key((string) $adapter_key),
     5067                'record_type' => $record_type,
     5068            ],
     5069            'candidate_sources' => array_values($sources),
     5070            'source_capability_profiles' => $capability_profiles,
     5071            'reliability_policy' => 'declared_capabilities_only_no_numeric_score_or_inferred_trust_rank',
     5072            'candidate_source_count' => count($sources),
     5073            'candidate_record_count' => array_sum(array_column(array_values($sources), 'candidate_records')),
     5074            'same_instant_event_candidates' => $same_instant_event_candidates,
     5075            'same_bounds_aggregate_candidates' => $same_bounds_aggregate_candidates,
     5076            'intersecting_aggregate_candidates' => $intersecting_aggregate_candidates,
     5077            'period_comparison_semantics' => 'raw_datetime_string_representation_only_timezone_unverified',
     5078            'different_record_type_candidates' => $different_type_candidates,
     5079            'candidate_set_truncated' => (bool) $truncated,
     5080            'identical_bucket_count_range' => $same_bucket_range,
     5081            'identity_confidence' => 'unproven_without_shared_event_identity',
     5082            'temporal_basis_status' => 'timezone_provenance_not_persisted',
     5083            'absolute_temporal_comparison_performed' => false,
     5084            'counting_decision' => 'preserve_contribution_pending_global_reconciliation',
     5085            'policy' => 'Stored datetime strings and identical period bounds are representation-level signals only because source timezone provenance is not persisted; no absolute-time overlap or unique-view range is asserted and no contribution is suppressed by this assessment.',
     5086        ];
     5087    }
     5088
     5089    /**
     5090     * Build a bounded product-wide inventory across every persisted generic source.
     5091     * This is a descriptive synthesis of recorded contributions, not a unique-view
     5092     * estimate: source totals are never added, ranked, or suppressed here.
     5093     */
     5094    private function build_generic_product_source_synthesis($product_id) {
     5095        global $wpdb;
     5096
     5097        $product_id = absint($product_id);
     5098        if (!$product_id) {
     5099            return ['status' => 'unavailable', 'reason' => 'invalid_product_id', 'sources' => []];
     5100        }
     5101
     5102        // Keep the source-ID inventory independent of the bounded record-group
     5103        // result so capability profiles cover every source, even when the
     5104        // descriptive record inventory reaches its output limit.
     5105        $source_id_rows = statpl_db_get_col(statpl_db_prepare(
     5106            "SELECT DISTINCT source_id
     5107             FROM {$this->view_source_events_table}
     5108             WHERE product_id = %d
     5109             ORDER BY source_id ASC",
     5110            $product_id
     5111        ));
     5112        if (!empty($wpdb->last_error) || !is_array($source_id_rows)) {
     5113            return ['status' => 'query_failed', 'reason' => 'product_source_id_inventory_query_failed', 'sources' => []];
     5114        }
     5115        $all_source_ids = array_values(array_unique(array_filter(array_map('absint', $source_id_rows))));
     5116
     5117        $rows = statpl_db_get_results(statpl_db_prepare(
     5118            "SELECT e.source_id, s.source_key, s.display_name, e.record_type,
     5119                    COUNT(*) AS record_count, SUM(e.observed_views) AS observed_views,
     5120                    SUM(e.applied_views) AS applied_views,
     5121                    MIN(e.period_start) AS first_period_start,
     5122                    MAX(e.period_end) AS last_period_end
     5123             FROM {$this->view_source_events_table} AS e
     5124             LEFT JOIN {$this->view_sources_table} AS s ON s.id = e.source_id
     5125             WHERE e.product_id = %d
     5126             GROUP BY e.source_id, s.source_key, s.display_name, e.record_type
     5127             ORDER BY e.source_id ASC, e.record_type ASC
     5128             LIMIT 101",
     5129            $product_id
     5130        ), ARRAY_A);
     5131        if (!empty($wpdb->last_error) || !is_array($rows)) {
     5132            return ['status' => 'query_failed', 'reason' => 'product_source_inventory_query_failed', 'sources' => []];
     5133        }
     5134        $truncated = count($rows) > 100;
     5135        if ($truncated) {
     5136            $rows = array_slice($rows, 0, 100);
     5137        }
     5138        $sources = [];
     5139        foreach ($rows as $row) {
     5140            $source_id = absint($row['source_id'] ?? 0);
     5141            if (!$source_id) { continue; }
     5142            if (!isset($sources[$source_id])) {
     5143                $sources[$source_id] = [
     5144                    'source_id' => $source_id,
     5145                    'source_key' => sanitize_key((string) ($row['source_key'] ?? '')),
     5146                    'display_name' => sanitize_text_field((string) ($row['display_name'] ?? '')),
     5147                    'record_types' => [],
     5148                ];
     5149            }
     5150            $sources[$source_id]['record_types'][] = [
     5151                'record_type' => sanitize_key((string) ($row['record_type'] ?? 'unknown')),
     5152                'record_count' => max(0, (int) ($row['record_count'] ?? 0)),
     5153                'observed_views' => max(0, (int) ($row['observed_views'] ?? 0)),
     5154                'applied_views' => max(0, (int) ($row['applied_views'] ?? 0)),
     5155                'first_period_start' => $row['first_period_start'] !== null ? (string) $row['first_period_start'] : null,
     5156                'last_period_end' => $row['last_period_end'] !== null ? (string) $row['last_period_end'] : null,
     5157            ];
     5158        }
     5159        ksort($sources, SORT_NUMERIC);
     5160
     5161        $capability_profiles = $this->get_generic_source_capability_profiles($all_source_ids);
     5162        $capability_profiles_by_source = [];
     5163        // A partial capability lookup still contains useful, successfully
     5164        // resolved profiles plus explicit unresolved entries. Retain every
     5165        // returned profile; the status separately communicates incompleteness.
     5166        if (in_array(($capability_profiles['status'] ?? ''), ['available', 'partial'], true)) {
     5167            foreach ((array) ($capability_profiles['sources'] ?? []) as $profile) {
     5168                $profile_source_id = absint($profile['source_id'] ?? 0);
     5169                if ($profile_source_id) {
     5170                    $capability_profiles_by_source[$profile_source_id] = $profile;
     5171                }
     5172            }
     5173        }
     5174
     5175        return [
     5176            'status' => 'available',
     5177            'scope' => 'all_persisted_generic_sources_for_product',
     5178            'sources' => array_values($sources),
     5179            'source_ids_in_scope' => $all_source_ids,
     5180            'source_count_in_scope' => count($all_source_ids),
     5181            'source_capability_profiles' => array_values($capability_profiles_by_source),
     5182            'capability_profile_status' => sanitize_key((string) ($capability_profiles['status'] ?? 'unknown')),
     5183            'capability_profile_source_count' => count($capability_profiles_by_source),
     5184            'source_record_groups_truncated' => $truncated,
     5185            'unique_view_total' => null,
     5186            'counting_decision' => 'no_cross_source_sum_or_suppression',
     5187            'policy' => 'Observed and applied contributions are reported separately by source and record type; declared capabilities are attached for every source in scope where available, without numeric reliability ranking or a deduplicated product-view total.',
     5188        ];
     5189    }
     5190
     5191    /**
     5192     * Combine the independently collected reconciliation evidence into one
     5193     * product-level audit summary. This is classification only: it never
     5194     * estimates unique views, changes applied_views, or authorizes suppression.
     5195     */
     5196    /**
     5197     * Classify the completeness and ambiguity of the retained cross-source candidate set.
     5198     * Candidate ordering identifies an audit primary only; it never establishes identity.
     5199     */
     5200    private function build_generic_candidate_set_assessment($candidates, $candidates_truncated = false, $observed_count = null) {
     5201        $candidates = is_array($candidates) ? $candidates : [];
     5202        $by_id = [];
     5203        $source_ids = [];
     5204        foreach ($candidates as $candidate) {
     5205            if (!is_array($candidate)) {
     5206                continue;
     5207            }
     5208            $event_id = isset($candidate['id']) ? (int) $candidate['id'] : 0;
     5209            $source_id = isset($candidate['source_id']) ? (int) $candidate['source_id'] : 0;
     5210            if ($event_id > 0) {
     5211                $by_id[$event_id] = true;
     5212            }
     5213            if ($source_id > 0) {
     5214                $source_ids[$source_id] = true;
     5215            }
     5216        }
     5217        $candidate_count = count($by_id);
     5218        $source_count = count($source_ids);
     5219        $observed_count = $observed_count === null ? $candidate_count : max($candidate_count, (int) $observed_count);
     5220        $truncated = (bool) $candidates_truncated;
     5221
     5222        if ($candidate_count > 1 || $truncated || $observed_count > $candidate_count) {
     5223            $classification = 'multiple_or_truncated_candidates_require_review';
     5224        } elseif ($candidate_count === 1) {
     5225            $classification = 'single_candidate_requires_review';
     5226        } else {
     5227            $classification = 'no_candidate_available';
     5228        }
     5229
     5230        return [
     5231            'classification' => $classification,
     5232            'candidate_count_retained' => $candidate_count,
     5233            'candidate_event_ids' => array_map('intval', array_keys($by_id)),
     5234            'candidate_source_ids_by_event_id' => array_intersect_key(array_reduce($candidates, static function ($carry, $candidate) {
     5235                if (is_array($candidate) && !empty($candidate['id']) && !empty($candidate['source_id'])) {
     5236                    $carry[(int) $candidate['id']] = (int) $candidate['source_id'];
     5237                }
     5238                return $carry;
     5239            }, []), $by_id),
     5240            'candidate_count_observed' => $observed_count,
     5241            'candidate_source_count_retained' => $source_count,
     5242            'multiple_candidate_ambiguity' => $candidate_count > 1 || $observed_count > 1,
     5243            'multiple_source_ambiguity' => $source_count > 1,
     5244            'candidate_set_truncated' => $truncated,
     5245            'candidate_count_is_lower_bound' => $truncated,
     5246            'primary_candidate_is_identity_proof' => false,
     5247            'automatic_suppression_authorized' => false,
     5248            'policy' => 'All retained candidates remain evidence. Primary ordering is for audit navigation only and does not resolve identity or authorize count suppression.',
     5249        ];
     5250    }
     5251
     5252    /**
     5253     * Describe conflicting or heterogeneous dimensions across retained candidates.
     5254     * Differences are classified as evidence only; this method never resolves identity
     5255     * or changes the contribution applied to product totals.
     5256     */
     5257    /**
     5258     * Validate the integrity of the per-candidate audit records. Missing or duplicate
     5259     * identifiers and malformed classifications are retained as audit errors; this
     5260     * method never infers identity or authorizes suppression.
     5261     */
     5262    private function build_generic_candidate_record_integrity_assessment($candidate_details, $expected_unique_candidate_count) {
     5263        $candidate_details = is_array($candidate_details) ? $candidate_details : [];
     5264        $expected_unique_candidate_count = max(0, (int) $expected_unique_candidate_count);
     5265        $errors = [];
     5266        $seen_ids = [];
     5267        $missing_id_count = 0;
     5268        $duplicate_id_count = 0;
     5269        $invalid_classification_count = 0;
     5270        $invalid_dimension_check_count = 0;
     5271        $allowed_classifications = [
     5272            'fingerprint_dimension_mismatch',
     5273            'temporal_candidate_different_record_shape',
     5274            'candidate_dimensions_match_without_identity_proof',
     5275            'insufficient_candidate_dimensions',
     5276        ];
     5277        $required_dimensions = ['product_id', 'record_type', 'observed_views', 'period_start', 'period_end'];
     5278
     5279        foreach ($candidate_details as $detail) {
     5280            if (!is_array($detail)) {
     5281                $missing_id_count++;
     5282                $invalid_classification_count++;
     5283                $invalid_dimension_check_count++;
     5284                continue;
     5285            }
     5286            $candidate_id = absint($detail['candidate_event_id'] ?? 0);
     5287            if (!$candidate_id) {
     5288                $missing_id_count++;
     5289            } elseif (isset($seen_ids[$candidate_id])) {
     5290                $duplicate_id_count++;
     5291            } else {
     5292                $seen_ids[$candidate_id] = true;
     5293            }
     5294            if (!in_array($detail['classification'] ?? null, $allowed_classifications, true)) {
     5295                $invalid_classification_count++;
     5296            }
     5297            $checks = $detail['dimension_checks'] ?? null;
     5298            $checks_valid = is_array($checks);
     5299            if ($checks_valid) {
     5300                foreach ($required_dimensions as $dimension) {
     5301                    if (!array_key_exists($dimension, $checks)
     5302                        || !in_array($checks[$dimension], [true, false, null], true)) {
     5303                        $checks_valid = false;
     5304                        break;
     5305                    }
     5306                }
     5307            }
     5308            if (!$checks_valid) {
     5309                $invalid_dimension_check_count++;
     5310            }
     5311        }
     5312
     5313        $detail_count = count($candidate_details);
     5314        $unique_id_count = count($seen_ids);
     5315        if ($missing_id_count > 0) {
     5316            $errors[] = 'candidate_audit_record_missing_valid_event_id';
     5317        }
     5318        if ($duplicate_id_count > 0) {
     5319            $errors[] = 'candidate_audit_records_contain_duplicate_event_ids';
     5320        }
     5321        if ($invalid_classification_count > 0) {
     5322            $errors[] = 'candidate_audit_record_has_invalid_classification';
     5323        }
     5324        if ($invalid_dimension_check_count > 0) {
     5325            $errors[] = 'candidate_audit_record_has_invalid_dimension_checks';
     5326        }
     5327        if ($detail_count !== $expected_unique_candidate_count || $unique_id_count !== $expected_unique_candidate_count) {
     5328            $errors[] = 'candidate_audit_record_count_does_not_match_unique_candidate_inventory';
     5329        }
     5330
     5331        return [
     5332            'status' => $errors ? 'candidate_records_require_review' : 'candidate_records_valid',
     5333            'consistent' => !$errors,
     5334            'errors' => array_values(array_unique($errors)),
     5335            'candidate_detail_count' => $detail_count,
     5336            'unique_valid_candidate_id_count' => $unique_id_count,
     5337            'expected_unique_candidate_count' => $expected_unique_candidate_count,
     5338            'missing_id_count' => $missing_id_count,
     5339            'duplicate_id_count' => $duplicate_id_count,
     5340            'invalid_classification_count' => $invalid_classification_count,
     5341            'invalid_dimension_check_count' => $invalid_dimension_check_count,
     5342            'identity_proven' => false,
     5343            'automatic_suppression_authorized' => false,
     5344            'policy' => 'Record validation is audit-only. Invalid records remain visible for review and cannot establish identity or authorize suppression.',
     5345        ];
     5346    }
     5347
     5348    private function build_generic_candidate_conflict_assessment($incoming, $candidates) {
     5349        $incoming = is_array($incoming) ? $incoming : [];
     5350        $candidates = is_array($candidates) ? $candidates : [];
     5351        $counts = [
     5352            'fingerprint_dimension_mismatch' => 0,
     5353            'temporal_candidate_different_record_shape' => 0,
     5354            'candidate_dimensions_match_without_identity_proof' => 0,
     5355            'insufficient_candidate_dimensions' => 0,
     5356        ];
     5357        $candidate_ids = [];
     5358        $dimension_signatures = [];
     5359        $details = [];
     5360        foreach ($candidates as $candidate) {
     5361            if (!is_array($candidate)) {
     5362                continue;
     5363            }
     5364            $candidate_id = absint($candidate['id'] ?? 0);
     5365            if ($candidate_id) {
     5366                $candidate_ids[$candidate_id] = true;
     5367            }
     5368            $discovered_by = array_values((array) ($candidate['_discovered_by'] ?? []));
     5369            $has_fingerprint_signal = in_array('fingerprint', $discovered_by, true);
     5370            $has_temporal_signal = in_array('temporal', $discovered_by, true);
     5371            $checks = [
     5372                'product_id' => isset($incoming['product_id'], $candidate['product_id'])
     5373                    ? (int) $incoming['product_id'] === (int) $candidate['product_id'] : null,
     5374                'record_type' => isset($incoming['record_type'], $candidate['record_type'])
     5375                    ? (string) $incoming['record_type'] === (string) $candidate['record_type'] : null,
     5376                'observed_views' => isset($incoming['observed_views'], $candidate['observed_views'])
     5377                    ? (int) $incoming['observed_views'] === (int) $candidate['observed_views'] : null,
     5378                'period_start' => !empty($incoming['period_start']) && !empty($candidate['period_start'])
     5379                    ? (string) $incoming['period_start'] === (string) $candidate['period_start'] : null,
     5380                'period_end' => !empty($incoming['period_end']) && !empty($candidate['period_end'])
     5381                    ? (string) $incoming['period_end'] === (string) $candidate['period_end'] : null,
     5382            ];
     5383            $known_checks = array_filter($checks, static function ($value) { return $value !== null; });
     5384            $all_known_dimensions_match = count($known_checks) === count($checks)
     5385                && !in_array(false, $known_checks, true);
     5386            $has_dimension_difference = in_array(false, $known_checks, true);
     5387            if ($has_fingerprint_signal && $has_dimension_difference) {
     5388                $classification = 'fingerprint_dimension_mismatch';
     5389            } elseif ($has_temporal_signal && (
     5390                $checks['record_type'] === false || $checks['observed_views'] === false
     5391            )) {
     5392                $classification = 'temporal_candidate_different_record_shape';
     5393            } elseif ($all_known_dimensions_match) {
     5394                $classification = 'candidate_dimensions_match_without_identity_proof';
     5395            } else {
     5396                $classification = 'insufficient_candidate_dimensions';
     5397            }
     5398            $counts[$classification]++;
     5399            $signature = implode('|', [
     5400                (string) ($candidate['product_id'] ?? 'unknown'),
     5401                (string) ($candidate['record_type'] ?? 'unknown'),
     5402                (string) ($candidate['observed_views'] ?? 'unknown'),
     5403                (string) ($candidate['period_start'] ?? ''),
     5404                (string) ($candidate['period_end'] ?? ''),
     5405            ]);
     5406            $dimension_signatures[$signature] = true;
     5407            $details[] = [
     5408                'candidate_event_id' => $candidate_id ?: null,
     5409                'candidate_source_id' => !empty($candidate['source_id']) ? (int) $candidate['source_id'] : null,
     5410                'discovered_by' => $discovered_by,
     5411                'classification' => $classification,
     5412                'dimension_checks' => $checks,
     5413                'identity_proven' => false,
     5414                'automatic_suppression_authorized' => false,
     5415            ];
     5416        }
     5417        $candidate_count = count($candidate_ids);
     5418        $record_integrity = $this->build_generic_candidate_record_integrity_assessment($details, $candidate_count);
     5419        $heterogeneous = count($dimension_signatures) > 1;
     5420        $has_conflict = $counts['fingerprint_dimension_mismatch'] > 0
     5421            || $counts['temporal_candidate_different_record_shape'] > 0;
     5422        return [
     5423            'status' => $candidate_count ? 'available' : 'no_candidates',
     5424            'candidate_count' => $candidate_count,
     5425            'candidate_dimension_signature_count' => count($dimension_signatures),
     5426            'candidate_set_is_heterogeneous' => $heterogeneous,
     5427            'conflicting_evidence_present' => $has_conflict,
     5428            'classification_counts' => $counts,
     5429            'candidate_record_integrity' => $record_integrity,
     5430            'candidates' => $details,
     5431            'identity_proven' => false,
     5432            'automatic_suppression_authorized' => false,
     5433            'policy' => 'Dimension differences and heterogeneous candidates are retained as review evidence only; they do not establish identity, select a winning candidate, or authorize count suppression.',
     5434        ];
     5435    }
     5436
     5437    /**
     5438     * Make an explicit audit decision from candidate support and contradiction evidence.
     5439     * Absence of candidates is not evidence that sources do not overlap. A clean dimensional
     5440     * comparison is not identity proof, and truncation always limits the conclusion.
     5441     */
     5442    private function build_generic_candidate_evidence_decision($conflict_assessment, $candidate_set_assessment = [], $candidate_search_truncated = false) {
     5443        $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : [];
     5444        $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : [];
     5445        $counts = (array) ($conflict_assessment['classification_counts'] ?? []);
     5446        $fingerprint_conflicts = max(0, (int) ($counts['fingerprint_dimension_mismatch'] ?? 0));
     5447        $temporal_shape_conflicts = max(0, (int) ($counts['temporal_candidate_different_record_shape'] ?? 0));
     5448        $matching_dimensions = max(0, (int) ($counts['candidate_dimensions_match_without_identity_proof'] ?? 0));
     5449        $insufficient_dimensions = max(0, (int) ($counts['insufficient_candidate_dimensions'] ?? 0));
     5450        $candidate_count = max(0, (int) ($conflict_assessment['candidate_count'] ?? 0));
     5451        $truncated = (bool) $candidate_search_truncated
     5452            || !empty($candidate_set_assessment['candidate_set_truncated'])
     5453            || !empty($candidate_set_assessment['candidate_count_is_lower_bound']);
     5454
     5455        $support = [];
     5456        $contradictions = [];
     5457        $limitations = [];
     5458        if ($matching_dimensions > 0) {
     5459            $support[] = 'candidate_dimensions_match_but_do_not_prove_shared_identity';
     5460        }
     5461        if ($fingerprint_conflicts > 0) {
     5462            $contradictions[] = 'fingerprint_discovery_signal_has_dimension_mismatches';
     5463        }
     5464        if ($temporal_shape_conflicts > 0) {
     5465            $contradictions[] = 'temporal_candidates_have_different_record_shapes';
     5466        }
     5467        if ($insufficient_dimensions > 0) {
     5468            $limitations[] = 'candidate_dimensions_are_incomplete';
     5469        }
     5470        if ($truncated) {
     5471            $limitations[] = 'candidate_search_truncated_conclusion_is_incomplete';
     5472        }
     5473
     5474        if ($candidate_count === 0) {
     5475            $classification = $truncated
     5476                ? 'candidate_search_incomplete_no_retained_candidate_no_overlap_conclusion'
     5477                : 'no_candidate_found_within_search_bounds_overlap_remains_unknown';
     5478        } elseif ($fingerprint_conflicts > 0 || $temporal_shape_conflicts > 0) {
     5479            $classification = ($matching_dimensions > 0)
     5480                ? 'supporting_and_contradictory_evidence_require_review'
     5481                : 'contradictory_candidate_evidence_requires_review';
     5482        } elseif ($matching_dimensions > 0) {
     5483            $classification = 'supportive_dimensions_without_identity_proof';
     5484        } else {
     5485            $classification = 'insufficient_candidate_evidence_requires_review';
     5486        }
     5487        if ($truncated && $candidate_count > 0) {
     5488            $limitations[] = 'retained_candidates_are_not_the_complete_observed_set';
     5489        }
     5490
     5491        return [
     5492            'classification' => $classification,
     5493            'supporting_evidence' => array_values(array_unique($support)),
     5494            'contradictory_evidence' => array_values(array_unique($contradictions)),
     5495            'limitations' => array_values(array_unique($limitations)),
     5496            'supporting_candidate_count' => $matching_dimensions,
     5497            'fingerprint_dimension_conflict_count' => $fingerprint_conflicts,
     5498            'temporal_record_shape_conflict_count' => $temporal_shape_conflicts,
     5499            'insufficient_dimension_candidate_count' => $insufficient_dimensions,
     5500            'candidate_count' => $candidate_count,
     5501            'candidate_search_truncated' => $truncated,
     5502            'absence_of_candidates_proves_no_overlap' => false,
     5503            'identity_proven' => false,
     5504            'automatic_suppression_authorized' => false,
     5505            'policy' => 'Support, contradiction, and missing evidence are reported separately. No candidate is not proof of no overlap; matching dimensions are not proof of identity; no evidence classification changes applied_views or authorizes suppression.',
     5506        ];
     5507    }
     5508
     5509    /**
     5510     * Validate that candidate inventory, conflict assessment, and evidence decision
     5511     * describe the same retained evidence and preserve the non-suppression policy.
     5512     * This is an audit integrity check only; it never changes reconciliation outcomes.
     5513     */
     5514    private function build_generic_candidate_audit_consistency($candidate_set_assessment, $conflict_assessment, $evidence_decision) {
     5515        $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : [];
     5516        $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : [];
     5517        $evidence_decision = is_array($evidence_decision) ? $evidence_decision : [];
     5518        $errors = [];
     5519        $candidate_count = max(0, (int) ($conflict_assessment['candidate_count'] ?? 0));
     5520        $retained_count = max(0, (int) ($candidate_set_assessment['candidate_count_retained'] ?? 0));
     5521        $decision_count = max(0, (int) ($evidence_decision['candidate_count'] ?? 0));
     5522        $counts = (array) ($conflict_assessment['classification_counts'] ?? []);
     5523        $record_integrity = $conflict_assessment['candidate_record_integrity'] ?? null;
     5524        $record_integrity = is_array($record_integrity) ? $record_integrity : [];
     5525        $classified_count = 0;
     5526        foreach ([
     5527            'fingerprint_dimension_mismatch',
     5528            'temporal_candidate_different_record_shape',
     5529            'candidate_dimensions_match_without_identity_proof',
     5530            'insufficient_candidate_dimensions',
     5531        ] as $key) {
     5532            $classified_count += max(0, (int) ($counts[$key] ?? 0));
     5533        }
     5534        if ($candidate_count !== $retained_count || $candidate_count !== $decision_count) {
     5535            $errors[] = 'candidate_counts_disagree_across_audit_layers';
     5536        }
     5537        if (empty($conflict_assessment['candidate_record_integrity']) || !is_array($conflict_assessment['candidate_record_integrity'])) {
     5538            $errors[] = 'candidate_record_integrity_assessment_missing_from_conflict_audit';
     5539        } elseif (empty($record_integrity['consistent'])) {
     5540            $errors[] = 'candidate_record_integrity_validation_failed';
     5541        }
     5542        if (isset($record_integrity['expected_unique_candidate_count'])
     5543            && (int) $record_integrity['expected_unique_candidate_count'] !== $candidate_count) {
     5544            $errors[] = 'candidate_record_integrity_expected_count_disagrees_with_conflict_inventory';
     5545        }
     5546        if ($classified_count > $candidate_count) {
     5547            $errors[] = 'candidate_classification_count_exceeds_candidate_inventory';
     5548        } elseif ($classified_count < $candidate_count) {
     5549            $errors[] = 'candidate_classification_coverage_incomplete';
     5550        }
     5551        $classification_coverage_complete = $classified_count === $candidate_count;
     5552        foreach ([
     5553            'supporting_candidate_count' => 'candidate_dimensions_match_without_identity_proof',
     5554            'fingerprint_dimension_conflict_count' => 'fingerprint_dimension_mismatch',
     5555            'temporal_record_shape_conflict_count' => 'temporal_candidate_different_record_shape',
     5556            'insufficient_dimension_candidate_count' => 'insufficient_candidate_dimensions',
     5557        ] as $decision_key => $conflict_key) {
     5558            if (max(0, (int) ($evidence_decision[$decision_key] ?? 0)) !== max(0, (int) ($counts[$conflict_key] ?? 0))) {
     5559                $errors[] = 'evidence_decision_count_mismatch_' . $decision_key;
     5560            }
     5561        }
     5562        $set_truncated = !empty($candidate_set_assessment['candidate_set_truncated']);
     5563        $decision_truncated = !empty($evidence_decision['candidate_search_truncated']);
     5564        if ($set_truncated && !$decision_truncated) {
     5565            $errors[] = 'candidate_truncation_not_propagated_to_evidence_decision';
     5566        }
     5567        if (!empty($conflict_assessment['identity_proven']) || !empty($evidence_decision['identity_proven'])) {
     5568            $errors[] = 'candidate_evidence_layer_claims_identity_proof';
     5569        }
     5570        if (!empty($candidate_set_assessment['automatic_suppression_authorized'])
     5571            || !empty($conflict_assessment['automatic_suppression_authorized'])
     5572            || !empty($evidence_decision['automatic_suppression_authorized'])) {
     5573            $errors[] = 'candidate_evidence_layer_authorizes_automatic_suppression';
     5574        }
     5575        if ($candidate_count === 0 && !empty($evidence_decision['absence_of_candidates_proves_no_overlap'])) {
     5576            $errors[] = 'absence_of_candidates_misrepresented_as_no_overlap_proof';
     5577        }
     5578        return [
     5579            'status' => $errors ? 'inconsistent_audit_evidence_requires_review' : 'candidate_audit_layers_consistent',
     5580            'consistent' => !$errors,
     5581            'errors' => array_values(array_unique($errors)),
     5582            'candidate_count_conflict_assessment' => $candidate_count,
     5583            'candidate_count_inventory' => $retained_count,
     5584            'candidate_count_evidence_decision' => $decision_count,
     5585            'classified_candidate_count' => $classified_count,
     5586            'unclassified_candidate_count' => max(0, $candidate_count - $classified_count),
     5587            'classification_coverage_complete' => $classification_coverage_complete,
     5588            'candidate_record_integrity' => [
     5589                'present' => !empty($conflict_assessment['candidate_record_integrity']) && is_array($conflict_assessment['candidate_record_integrity']),
     5590                'consistent' => !empty($record_integrity['consistent']),
     5591                'errors' => array_values((array) ($record_integrity['errors'] ?? [])),
     5592                'candidate_detail_count' => max(0, (int) ($record_integrity['candidate_detail_count'] ?? 0)),
     5593                'unique_valid_candidate_id_count' => max(0, (int) ($record_integrity['unique_valid_candidate_id_count'] ?? 0)),
     5594                'expected_unique_candidate_count' => max(0, (int) ($record_integrity['expected_unique_candidate_count'] ?? 0)),
     5595            ],
     5596            'candidate_search_truncated' => $decision_truncated,
     5597            'identity_proven' => false,
     5598            'automatic_suppression_authorized' => false,
     5599            'policy' => 'Consistency validation is audit-only. A failed validation requires review and never changes applied_views or authorizes count suppression.',
     5600        ];
     5601    }
     5602
     5603    /**
     5604     * Compare the event/source references retained by candidate inventory and
     5605     * per-candidate conflict records. Reference mismatches require review only.
     5606     */
     5607    private function build_generic_candidate_reference_consistency($candidate_set_assessment, $conflict_assessment) {
     5608        $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : [];
     5609        $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : [];
     5610        $inventory_ids = array_values(array_unique(array_filter(array_map('absint', (array) ($candidate_set_assessment['candidate_event_ids'] ?? [])))));
     5611        $detail_ids = [];
     5612        $detail_sources = [];
     5613        $duplicate_detail_ids = [];
     5614        foreach ((array) ($conflict_assessment['candidates'] ?? []) as $detail) {
     5615            if (!is_array($detail) || empty($detail['candidate_event_id'])) {
     5616                continue;
     5617            }
     5618            $id = absint($detail['candidate_event_id']);
     5619            if (isset($detail_ids[$id])) {
     5620                $duplicate_detail_ids[] = $id;
     5621            }
     5622            $detail_ids[$id] = true;
     5623            $detail_sources[$id] = isset($detail['candidate_source_id']) ? absint($detail['candidate_source_id']) : 0;
     5624        }
     5625        $inventory_set = array_fill_keys($inventory_ids, true);
     5626        $detail_set = $detail_ids;
     5627        $missing_details = array_values(array_diff($inventory_ids, array_keys($detail_set)));
     5628        $unexpected_details = array_values(array_diff(array_keys($detail_set), $inventory_ids));
     5629        $source_mismatches = [];
     5630        $inventory_sources = (array) ($candidate_set_assessment['candidate_source_ids_by_event_id'] ?? []);
     5631        foreach ($inventory_sources as $event_id => $source_id) {
     5632            $event_id = absint($event_id);
     5633            $source_id = absint($source_id);
     5634            if (isset($detail_sources[$event_id]) && $detail_sources[$event_id] !== $source_id) {
     5635                $source_mismatches[] = $event_id;
     5636            }
     5637        }
     5638        $errors = [];
     5639        if ($missing_details) { $errors[] = 'candidate_inventory_event_missing_conflict_detail'; }
     5640        if ($unexpected_details) { $errors[] = 'conflict_detail_event_missing_from_candidate_inventory'; }
     5641        if ($duplicate_detail_ids) { $errors[] = 'conflict_details_contain_duplicate_event_references'; }
     5642        if ($source_mismatches) { $errors[] = 'candidate_source_reference_mismatch'; }
     5643        return [
     5644            'status' => $errors ? 'candidate_references_require_review' : 'candidate_references_consistent',
     5645            'consistent' => !$errors,
     5646            'errors' => $errors,
     5647            'inventory_event_ids' => $inventory_ids,
     5648            'conflict_detail_event_ids' => array_map('intval', array_keys($detail_set)),
     5649            'missing_conflict_detail_event_ids' => $missing_details,
     5650            'unexpected_conflict_detail_event_ids' => $unexpected_details,
     5651            'duplicate_conflict_detail_event_ids' => array_values(array_unique($duplicate_detail_ids)),
     5652            'source_reference_mismatch_event_ids' => array_values(array_unique($source_mismatches)),
     5653            'identity_proven' => false,
     5654            'automatic_suppression_authorized' => false,
     5655            'policy' => 'Reference consistency validates audit traceability only; it does not establish shared event identity or authorize suppression.',
     5656        ];
     5657    }
     5658
     5659    /**
     5660     * Validate discovery-signal provenance for every retained candidate. Signals
     5661     * explain why a candidate was found; they are never treated as identity proof.
     5662     */
     5663    private function build_generic_candidate_discovery_signal_consistency($candidate_set_assessment, $conflict_assessment) {
     5664        $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : [];
     5665        $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : [];
     5666        $inventory_ids = array_values(array_unique(array_filter(array_map('absint', (array) ($candidate_set_assessment['candidate_event_ids'] ?? [])))));
     5667        $allowed_signals = ['fingerprint', 'temporal'];
     5668        $candidate_signals = [];
     5669        $missing_signal_ids = [];
     5670        $unknown_signals = [];
     5671        $duplicate_signals = [];
     5672        $unexpected_ids = [];
     5673        $inventory_set = array_fill_keys($inventory_ids, true);
     5674        foreach ((array) ($conflict_assessment['candidates'] ?? []) as $detail) {
     5675            if (!is_array($detail) || empty($detail['candidate_event_id'])) {
     5676                continue;
     5677            }
     5678            $event_id = absint($detail['candidate_event_id']);
     5679            if (!isset($inventory_set[$event_id])) {
     5680                $unexpected_ids[] = $event_id;
     5681            }
     5682            $signals = $detail['discovered_by'] ?? null;
     5683            if (!is_array($signals) || !$signals) {
     5684                $missing_signal_ids[] = $event_id;
     5685                $candidate_signals[$event_id] = [];
     5686                continue;
     5687            }
     5688            $normalized = [];
     5689            foreach ($signals as $signal) {
     5690                if (!is_string($signal) || !in_array($signal, $allowed_signals, true)) {
     5691                    $unknown_signals[] = ['candidate_event_id' => $event_id, 'signal' => is_scalar($signal) ? (string) $signal : gettype($signal)];
     5692                    continue;
     5693                }
     5694                if (in_array($signal, $normalized, true)) {
     5695                    $duplicate_signals[] = ['candidate_event_id' => $event_id, 'signal' => $signal];
     5696                }
     5697                $normalized[] = $signal;
     5698            }
     5699            $candidate_signals[$event_id] = array_values(array_unique($normalized));
     5700        }
     5701        $missing_candidate_ids = array_values(array_diff($inventory_ids, array_keys($candidate_signals)));
     5702        $errors = [];
     5703        if ($missing_signal_ids) { $errors[] = 'candidate_discovery_signal_missing'; }
     5704        if ($missing_candidate_ids) { $errors[] = 'candidate_discovery_record_missing'; }
     5705        if ($unexpected_ids) { $errors[] = 'discovery_signal_candidate_outside_inventory'; }
     5706        if ($unknown_signals) { $errors[] = 'unknown_candidate_discovery_signal'; }
     5707        if ($duplicate_signals) { $errors[] = 'duplicate_candidate_discovery_signal'; }
     5708        return [
     5709            'status' => $errors ? 'candidate_discovery_signals_require_review' : 'candidate_discovery_signals_consistent',
     5710            'consistent' => !$errors,
     5711            'errors' => $errors,
     5712            'allowed_signal_types' => $allowed_signals,
     5713            'candidate_signals_by_event_id' => $candidate_signals,
     5714            'missing_signal_event_ids' => array_values(array_unique($missing_signal_ids)),
     5715            'missing_candidate_record_event_ids' => $missing_candidate_ids,
     5716            'unexpected_candidate_event_ids' => array_values(array_unique($unexpected_ids)),
     5717            'unknown_signals' => $unknown_signals,
     5718            'duplicate_signals' => $duplicate_signals,
     5719            'identity_proven' => false,
     5720            'automatic_suppression_authorized' => false,
     5721            'policy' => 'Fingerprint and temporal signals describe discovery provenance only; neither signal establishes shared event identity or authorizes suppression.',
     5722        ];
     5723    }
     5724
     5725    /**
     5726     * Verify that adapter evidence preserves the discovery signals recorded for
     5727     * each candidate. Discovery provenance remains advisory and is never identity proof.
     5728     */
     5729    private function build_generic_adapter_discovery_evidence_consistency($conflict_assessment, $adapter_overlap_evidence) {
     5730        $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : [];
     5731        $adapter_overlap_evidence = is_array($adapter_overlap_evidence) ? $adapter_overlap_evidence : [];
     5732        $expected = [];
     5733        foreach ((array) ($conflict_assessment['candidates'] ?? []) as $candidate) {
     5734            if (!is_array($candidate) || empty($candidate['candidate_event_id'])) { continue; }
     5735            $id = absint($candidate['candidate_event_id']);
     5736            $expected[$id] = array_values(array_unique(array_filter((array) ($candidate['discovered_by'] ?? []), 'is_string')));
     5737        }
     5738        $seen = [];
     5739        $missing = [];
     5740        $unexpected = [];
     5741        $mismatched = [];
     5742        $duplicate = [];
     5743        $invalid = [];
     5744        $allowed = ['fingerprint', 'temporal'];
     5745        foreach ($adapter_overlap_evidence as $entry) {
     5746            if (!is_array($entry) || empty($entry['candidate_event_id'])) { $invalid[] = 'missing_candidate_event_id'; continue; }
     5747            $id = absint($entry['candidate_event_id']);
     5748            if (isset($seen[$id])) { $duplicate[] = $id; }
     5749            $seen[$id] = true;
     5750            if (!array_key_exists($id, $expected)) { $unexpected[] = $id; continue; }
     5751            $signals = $entry['discovered_by'] ?? null;
     5752            if (!is_array($signals)) { $invalid[] = $id; continue; }
     5753            foreach ($signals as $signal) {
     5754                if (!is_string($signal) || !in_array($signal, $allowed, true)) { $invalid[] = $id; }
     5755            }
     5756            $normalized = array_values(array_unique(array_filter($signals, 'is_string')));
     5757            sort($normalized);
     5758            $expected_normalized = $expected[$id];
     5759            sort($expected_normalized);
     5760            $comparison_signals = $entry['comparison']['identity_assessment']['candidate_discovery_signals'] ?? null;
     5761            if (!is_array($comparison_signals)) {
     5762                $mismatched[] = $id;
     5763            } else {
     5764                $comparison_normalized = array_values(array_unique(array_filter($comparison_signals, 'is_string')));
     5765                sort($comparison_normalized);
     5766                if ($comparison_normalized !== $normalized) { $mismatched[] = $id; }
     5767            }
     5768            if ($normalized !== $expected_normalized) { $mismatched[] = $id; }
     5769        }
     5770        $missing = array_values(array_diff(array_keys($expected), array_keys($seen)));
     5771        $errors = [];
     5772        if ($missing) { $errors[] = 'adapter_discovery_evidence_missing_candidates'; }
     5773        if ($unexpected) { $errors[] = 'adapter_discovery_evidence_has_unexpected_candidates'; }
     5774        if ($mismatched) { $errors[] = 'adapter_discovery_signals_disagree_with_candidate_inventory'; }
     5775        if ($duplicate) { $errors[] = 'adapter_discovery_evidence_has_duplicate_candidates'; }
     5776        if ($invalid) { $errors[] = 'adapter_discovery_evidence_has_invalid_signals_or_records'; }
     5777        return [
     5778            'status' => $errors ? 'adapter_discovery_evidence_requires_review' : 'adapter_discovery_evidence_consistent',
     5779            'consistent' => !$errors,
     5780            'errors' => $errors,
     5781            'candidate_count_expected' => count($expected),
     5782            'candidate_count_observed' => count($seen),
     5783            'missing_candidate_event_ids' => $missing,
     5784            'unexpected_candidate_event_ids' => array_values(array_unique($unexpected)),
     5785            'mismatched_candidate_event_ids' => array_values(array_unique($mismatched)),
     5786            'duplicate_candidate_event_ids' => array_values(array_unique($duplicate)),
     5787            'invalid_candidate_records' => array_values(array_unique($invalid, SORT_REGULAR)),
     5788            'identity_proven' => false,
     5789            'automatic_suppression_authorized' => false,
     5790            'policy' => 'Adapter evidence must preserve discovery provenance; matching signals remain discovery evidence only and never establish shared event identity.',
     5791        ];
     5792    }
     5793
     5794    /**
     5795     * Compare the normalized dimensions in adapter evidence with the dimensions
     5796     * independently classified for each candidate. This validates audit transport,
     5797     * not shared identity; missing values remain unknown rather than matching.
     5798     */
     5799    /**
     5800     * Classify temporal granularity without treating interval overlap as identity.
     5801     * Daily windows, aggregate periods and point timestamps are distinct evidence shapes.
     5802     */
     5803    /**
     5804     * Describe the temporal basis that can actually be established from the
     5805     * adapter contract. The current event table does not persist a timezone or
     5806     * timezone-version fingerprint, so even WordPress-local values produced by
     5807     * two adapters cannot safely be compared as absolute instants across imports.
     5808     * Calendar aggregates retain date-label semantics and are not converted to
     5809     * timestamps. This helper is descriptive and never authorizes suppression.
     5810     */
     5811    private function classify_reconciliation_temporal_basis($adapter_key, $origin_key, $record_type) {
     5812        $adapter_key = sanitize_key((string) $adapter_key);
     5813        $origin_key = sanitize_key((string) $origin_key);
     5814        $record_type = sanitize_key((string) $record_type);
     5815
     5816        if ($record_type === 'aggregate') {
     5817            return [
     5818                'basis' => 'aggregate_period_timezone_unverified',
     5819                'source_representation' => 'date_bounded_aggregate_or_period',
     5820                'absolute_time_comparable' => false,
     5821                'reason' => 'The adapter imports an aggregate period, but its exact calendar semantics, source timezone, and historical site-timezone setting are not persisted here.',
     5822            ];
     5823        }
     5824
     5825        if ($record_type === 'event' && $adapter_key === 'burst_statistics') {
     5826            return [
     5827                'basis' => 'wordpress_site_local_timezone_unrecorded',
     5828                'source_representation' => 'unix_timestamp_converted_with_wp_date',
     5829                'absolute_time_comparable' => false,
     5830                'reason' => 'The source instant is known, but the WordPress timezone identifier/offset at import time is not persisted with the event.',
     5831            ];
     5832        }
     5833
     5834        if ($record_type === 'event' && $adapter_key === 'independent_analytics') {
     5835            return [
     5836                'basis' => 'wordpress_site_local_timezone_unrecorded',
     5837                'source_representation' => 'utc_timestamp_converted_to_wp_local_with_wp_date',
     5838                'absolute_time_comparable' => false,
     5839                'reason' => 'The source is UTC and is converted to WordPress local time, but the conversion timezone identifier/offset is not persisted with the event.',
     5840            ];
     5841        }
     5842
     5843        if ($record_type === 'event' && $adapter_key === 'pulseviews' && $origin_key === 'recent_raw_views') {
     5844            return [
     5845                'basis' => 'source_local_timezone_unknown',
     5846                'source_representation' => 'naive_sql_datetime',
     5847                'absolute_time_comparable' => false,
     5848                'reason' => 'The raw source datetime has no documented timezone or UTC offset.',
     5849            ];
     5850        }
     5851
     5852        return [
     5853            'basis' => 'unknown',
     5854            'source_representation' => 'unverified',
     5855            'absolute_time_comparable' => false,
     5856            'reason' => 'The adapter does not provide sufficient persisted timezone provenance for absolute-time comparison.',
     5857        ];
     5858    }
     5859
     5860    private function classify_reconciliation_temporal_granularity($record_type, $period_start, $period_end, $precision_hint = 'unknown') {
     5861        if (!$this->is_valid_reconciliation_period_range($period_start, $period_end)) {
     5862            return [
     5863                'granularity' => 'unknown',
     5864                'classification' => 'insufficient_or_invalid_temporal_bounds',
     5865                'comparable_at_same_granularity' => false,
     5866            ];
     5867        }
     5868        if ($precision_hint === 'calendar_day'
     5869            || (substr($period_start, 11) === '00:00:00' && substr($period_end, 11) === '23:59:59')) {
     5870            return [
     5871                'granularity' => 'calendar_day',
     5872                'classification' => 'day_level_interval',
     5873                'comparable_at_same_granularity' => true,
     5874            ];
     5875        }
     5876        if ($record_type === 'aggregate') {
     5877            return [
     5878                'granularity' => 'aggregate_period',
     5879                'classification' => 'aggregate_interval',
     5880                'comparable_at_same_granularity' => true,
     5881            ];
     5882        }
     5883        if ($record_type === 'event' && $period_start === $period_end) {
     5884            return [
     5885                'granularity' => 'point_timestamp',
     5886                'classification' => 'event_timestamp_unverified',
     5887                'comparable_at_same_granularity' => true,
     5888            ];
     5889        }
     5890        return [
     5891            'granularity' => 'unknown',
     5892            'classification' => 'unrecognized_temporal_shape',
     5893            'comparable_at_same_granularity' => false,
     5894        ];
     5895    }
     5896
     5897    /**
     5898     * Accept only canonical local SQL datetime values for reconciliation periods.
     5899     * This validates representation, not timezone equivalence or event identity.
     5900     */
     5901    private function is_valid_reconciliation_period_datetime($value) {
     5902        if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $value)) {
     5903            return false;
     5904        }
     5905        $parsed = DateTime::createFromFormat('!Y-m-d H:i:s', $value);
     5906        $errors = DateTime::getLastErrors();
     5907        return $parsed instanceof DateTime
     5908            && $parsed->format('Y-m-d H:i:s') === $value
     5909            && ($errors === false || (empty($errors['warning_count']) && empty($errors['error_count'])));
     5910    }
     5911
     5912    /**
     5913     * Validate chronological ordering when both period boundaries are known.
     5914     * Canonical local SQL datetimes can be compared lexically; this does not
     5915     * infer timezone equivalence or prove event identity.
     5916     */
     5917    private function is_valid_reconciliation_period_range($period_start, $period_end) {
     5918        if (!$this->is_valid_reconciliation_period_datetime($period_start)
     5919            || !$this->is_valid_reconciliation_period_datetime($period_end)) {
     5920            return null;
     5921        }
     5922        return strcmp($period_end, $period_start) >= 0;
     5923    }
     5924
     5925    private function build_generic_adapter_dimension_evidence_consistency($conflict_assessment, $adapter_overlap_evidence) {
     5926        $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : [];
     5927        $adapter_overlap_evidence = is_array($adapter_overlap_evidence) ? $adapter_overlap_evidence : [];
     5928        $expected = [];
     5929        foreach ((array) ($conflict_assessment['candidates'] ?? []) as $candidate) {
     5930            if (!is_array($candidate) || empty($candidate['candidate_event_id'])) { continue; }
     5931            $expected[absint($candidate['candidate_event_id'])] = (array) ($candidate['dimension_checks'] ?? []);
     5932        }
     5933        $seen = [];
     5934        $missing = [];
     5935        $unexpected = [];
     5936        $mismatched = [];
     5937        $incomplete = [];
     5938        $invalid_period_ranges = [];
     5939        $dimensions = ['product_id', 'record_type', 'observed_views', 'period_start', 'period_end'];
     5940        foreach ($adapter_overlap_evidence as $entry) {
     5941            if (!is_array($entry) || empty($entry['candidate_event_id'])) { $incomplete[] = 'missing_candidate_event_id'; continue; }
     5942            $id = absint($entry['candidate_event_id']);
     5943            if (isset($seen[$id])) { $mismatched[] = $id; }
     5944            $seen[$id] = true;
     5945            if (!array_key_exists($id, $expected)) { $unexpected[] = $id; continue; }
     5946            $incoming = $entry['comparison']['incoming'] ?? null;
     5947            $candidate = $entry['comparison']['candidate'] ?? null;
     5948            if (!is_array($incoming) || !is_array($candidate)) { $incomplete[] = $id; continue; }
     5949            $actual = [];
     5950            foreach ($dimensions as $dimension) {
     5951                if (!array_key_exists($dimension, $incoming) || !array_key_exists($dimension, $candidate)) {
     5952                    $actual[$dimension] = null;
     5953                    continue;
     5954                }
     5955                if ($dimension === 'product_id' || $dimension === 'observed_views') {
     5956                    $left_value = $incoming[$dimension];
     5957                    $right_value = $candidate[$dimension];
     5958                    if ($left_value === null || $right_value === null || $left_value === '' || $right_value === ''
     5959                        || !is_numeric($left_value) || !is_numeric($right_value)) {
     5960                        $actual[$dimension] = null;
     5961                        $incomplete[] = $id . ':' . $dimension;
     5962                    } else {
     5963                        $left_number = (float) $left_value;
     5964                        $right_number = (float) $right_value;
     5965                        if ($left_number < 0 || $right_number < 0
     5966                            || ($dimension === 'product_id' && ($left_number <= 0 || $right_number <= 0))) {
     5967                            $actual[$dimension] = null;
     5968                            $incomplete[] = $id . ':' . $dimension;
     5969                        } else {
     5970                            $actual[$dimension] = $left_number === $right_number;
     5971                        }
     5972                    }
     5973                } elseif ($dimension === 'record_type') {
     5974                    $left = is_scalar($incoming[$dimension]) ? (string) $incoming[$dimension] : '';
     5975                    $right = is_scalar($candidate[$dimension]) ? (string) $candidate[$dimension] : '';
     5976                    $actual[$dimension] = ($left !== '' && $right !== '') ? $left === $right : null;
     5977                    if ($actual[$dimension] === null) { $incomplete[] = $id . ':' . $dimension; }
     5978                } else {
     5979                    $left = $incoming[$dimension];
     5980                    $right = $candidate[$dimension];
     5981                    if (!$this->is_valid_reconciliation_period_datetime($left)
     5982                        || !$this->is_valid_reconciliation_period_datetime($right)) {
     5983                        $actual[$dimension] = null;
     5984                        $incomplete[] = $id . ':' . $dimension;
     5985                    } else {
     5986                        $actual[$dimension] = $left === $right;
     5987                    }
     5988                }
     5989            }
     5990            foreach (['incoming' => $incoming, 'candidate' => $candidate] as $side => $period_data) {
     5991                $range_valid = $this->is_valid_reconciliation_period_range(
     5992                    $period_data['period_start'] ?? null,
     5993                    $period_data['period_end'] ?? null
     5994                );
     5995                if ($range_valid === false) {
     5996                    $invalid_period_ranges[] = $id . ':' . $side;
     5997                    $mismatched[] = $id;
     5998                } elseif ($range_valid === null) {
     5999                    // Boundary validity is already reported per dimension above.
     6000                    // Do not turn unknown boundaries into an ordering conclusion.
     6001                }
     6002            }
     6003            $expected_checks = $expected[$id];
     6004            foreach ($dimensions as $dimension) {
     6005                if (!array_key_exists($dimension, $expected_checks) || $expected_checks[$dimension] !== $actual[$dimension]) {
     6006                    $mismatched[] = $id;
     6007                    break;
     6008                }
     6009            }
     6010        }
     6011        $missing = array_values(array_diff(array_keys($expected), array_keys($seen)));
     6012        $errors = [];
     6013        if ($missing) { $errors[] = 'adapter_dimension_evidence_missing_candidates'; }
     6014        if ($unexpected) { $errors[] = 'adapter_dimension_evidence_has_unexpected_candidates'; }
     6015        if ($mismatched) { $errors[] = 'adapter_dimension_checks_disagree_with_conflict_assessment'; }
     6016        if ($incomplete) { $errors[] = 'adapter_dimension_evidence_incomplete'; }
     6017        if ($invalid_period_ranges) { $errors[] = 'adapter_period_range_end_precedes_start'; }
     6018        return [
     6019            'status' => $errors ? 'adapter_dimension_evidence_requires_review' : 'adapter_dimension_evidence_consistent',
     6020            'consistent' => !$errors,
     6021            'errors' => $errors,
     6022            'candidate_count_expected' => count($expected),
     6023            'candidate_count_observed' => count($seen),
     6024            'missing_candidate_event_ids' => $missing,
     6025            'unexpected_candidate_event_ids' => array_values(array_unique($unexpected)),
     6026            'mismatched_candidate_event_ids' => array_values(array_unique($mismatched)),
     6027            'incomplete_candidate_records' => array_values(array_unique($incomplete, SORT_REGULAR)),
     6028            'invalid_period_ranges' => array_values(array_unique($invalid_period_ranges)),
     6029            'identity_proven' => false,
     6030            'automatic_suppression_authorized' => false,
     6031            'policy' => 'Adapter dimension evidence must reproduce the independently recorded comparison checks. Missing dimensions remain unknown; reversed known period boundaries require review. Consistency validates audit integrity only and never proves shared event identity.',
     6032        ];
     6033    }
     6034
     6035    /**
     6036     * Validate the complete candidate reconciliation audit chain, including the
     6037     * independent candidate ordering checks. This is diagnostic only and cannot
     6038     * promote evidence to identity proof or change any applied contribution.
     6039     */
     6040    private function build_generic_candidate_reconciliation_chain_assessment($candidate_set_assessment, $conflict_assessment, $evidence_decision, $audit_consistency, $candidate_consistency, $reference_consistency = [], $discovery_signal_consistency = []) {
     6041        $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : [];
     6042        $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : [];
     6043        $evidence_decision = is_array($evidence_decision) ? $evidence_decision : [];
     6044        $audit_consistency = is_array($audit_consistency) ? $audit_consistency : [];
     6045        $candidate_consistency = is_array($candidate_consistency) ? $candidate_consistency : [];
     6046        $reference_consistency = is_array($reference_consistency) ? $reference_consistency : [];
     6047        $discovery_signal_consistency = is_array($discovery_signal_consistency) ? $discovery_signal_consistency : [];
     6048        $errors = [];
     6049
     6050        $inventory_count = max(0, (int) ($candidate_set_assessment['candidate_count_retained'] ?? 0));
     6051        $conflict_count = max(0, (int) ($conflict_assessment['candidate_count'] ?? 0));
     6052        $decision_count = max(0, (int) ($evidence_decision['candidate_count'] ?? 0));
     6053        $ordering_count = max(0, (int) ($candidate_consistency['candidate_count'] ?? 0));
     6054        $adapter_count = max(0, (int) ($candidate_consistency['adapter_evidence_candidate_count'] ?? 0));
     6055
     6056        if (count(array_unique([$inventory_count, $conflict_count, $decision_count, $ordering_count])) > 1) {
     6057            $errors[] = 'candidate_inventory_conflict_decision_and_ordering_counts_disagree';
     6058        }
     6059        if (!empty($candidate_consistency['adapter_evidence_order_consistent']) && $adapter_count !== $ordering_count) {
     6060            $errors[] = 'adapter_evidence_count_disagrees_despite_order_consistency_claim';
     6061        }
     6062        if (!empty($candidate_consistency['primary_in_candidate_list']) === false) {
     6063            $errors[] = 'primary_candidate_missing_from_candidate_list';
     6064        }
     6065        if (!empty($candidate_consistency['adapter_evidence_primary_consistent']) === false) {
     6066            $errors[] = 'adapter_evidence_primary_candidate_inconsistent';
     6067        }
     6068        if (!empty($candidate_consistency['adapter_evidence_order_consistent']) === false) {
     6069            $errors[] = 'adapter_evidence_candidate_order_inconsistent';
     6070        }
     6071        if (array_key_exists('adapter_discovery_evidence_consistent', $candidate_consistency)
     6072            && empty($candidate_consistency['adapter_discovery_evidence_consistent'])) {
     6073            $errors[] = 'adapter_discovery_evidence_provenance_inconsistent';
     6074        }
     6075        if (array_key_exists('adapter_dimension_evidence_consistent', $candidate_consistency)
     6076            && empty($candidate_consistency['adapter_dimension_evidence_consistent'])) {
     6077            $errors[] = 'adapter_dimension_evidence_inconsistent_with_conflict_assessment';
     6078        }
     6079        if (empty($audit_consistency['consistent'])) {
     6080            $errors[] = 'underlying_candidate_audit_layers_are_inconsistent';
     6081        }
     6082        if (array_key_exists('consistent', $reference_consistency) && empty($reference_consistency['consistent'])) {
     6083            $errors[] = 'candidate_event_or_source_references_are_inconsistent';
     6084        }
     6085        if (array_key_exists('consistent', $discovery_signal_consistency) && empty($discovery_signal_consistency['consistent'])) {
     6086            $errors[] = 'candidate_discovery_signal_provenance_is_inconsistent';
     6087        }
     6088        if (array_key_exists('classification_coverage_complete', $audit_consistency)
     6089            && empty($audit_consistency['classification_coverage_complete'])) {
     6090            $errors[] = 'candidate_classification_coverage_incomplete_in_chain';
     6091        }
     6092        if (!empty($candidate_set_assessment['candidate_set_truncated']) && empty($evidence_decision['candidate_search_truncated'])) {
     6093            $errors[] = 'truncation_missing_from_final_candidate_decision';
     6094        }
     6095        if (!empty($evidence_decision['identity_proven']) || !empty($candidate_consistency['identity_proven'])) {
     6096            $errors[] = 'candidate_reconciliation_chain_claims_identity_proof';
     6097        }
     6098        if (!empty($evidence_decision['automatic_suppression_authorized']) || !empty($candidate_consistency['automatic_suppression_authorized'])) {
     6099            $errors[] = 'candidate_reconciliation_chain_authorizes_automatic_suppression';
     6100        }
     6101
     6102        return [
     6103            'status' => $errors ? 'reconciliation_chain_requires_review' : 'reconciliation_chain_consistent',
     6104            'consistent' => !$errors,
     6105            'errors' => array_values(array_unique($errors)),
     6106            'candidate_counts' => [
     6107                'inventory' => $inventory_count,
     6108                'conflict_assessment' => $conflict_count,
     6109                'evidence_decision' => $decision_count,
     6110                'candidate_ordering' => $ordering_count,
     6111                'adapter_evidence' => $adapter_count,
     6112            ],
     6113            'candidate_search_truncated' => !empty($evidence_decision['candidate_search_truncated']),
     6114            'classification_coverage_complete' => !empty($audit_consistency['classification_coverage_complete']),
     6115            'classified_candidate_count' => max(0, (int) ($audit_consistency['classified_candidate_count'] ?? 0)),
     6116            'unclassified_candidate_count' => max(0, (int) ($audit_consistency['unclassified_candidate_count'] ?? 0)),
     6117            'candidate_reference_consistency' => $reference_consistency,
     6118            'candidate_discovery_signal_consistency' => $discovery_signal_consistency,
     6119            'adapter_discovery_evidence_consistent' => !empty($candidate_consistency['adapter_discovery_evidence_consistent']),
     6120            'adapter_dimension_evidence_consistent' => !empty($candidate_consistency['adapter_dimension_evidence_consistent']),
     6121            'identity_proven' => false,
     6122            'automatic_suppression_authorized' => false,
     6123            'policy' => 'This end-to-end check validates audit-chain consistency only. Any inconsistency requires review; no result changes applied_views, totals, or count suppression.',
     6124        ];
     6125    }
     6126
     6127    private function build_generic_product_reconciliation_summary($product_id, $cross_source, $partial_overlap, $product_synthesis, $matched_event = null, $candidate_set = null) {
     6128        $cross_source = is_array($cross_source) ? $cross_source : [];
     6129        $partial_overlap = is_array($partial_overlap) ? $partial_overlap : [];
     6130        $product_synthesis = is_array($product_synthesis) ? $product_synthesis : [];
     6131        $candidate_set = is_array($candidate_set) ? $candidate_set : [];
     6132        $candidate_set_assessment = $this->build_generic_candidate_set_assessment(
     6133            $candidate_set['candidates'] ?? [],
     6134            !empty($candidate_set['truncated']),
     6135            $candidate_set['observed_count'] ?? null
     6136        );
     6137
     6138        $capability_status = sanitize_key((string) ($product_synthesis['capability_profile_status'] ?? 'unknown'));
     6139        $synthesis_status = sanitize_key((string) ($product_synthesis['status'] ?? 'unknown'));
     6140        $partial_status = sanitize_key((string) ($partial_overlap['status'] ?? 'unknown'));
     6141        $cross_truncated = !empty($cross_source['candidate_set_truncated']);
     6142        $inventory_truncated = !empty($product_synthesis['source_record_groups_truncated']);
     6143        $partial_truncated = !empty($partial_overlap['records_truncated']);
     6144
     6145        $limitations = [];
     6146        if ($synthesis_status !== 'available') { $limitations[] = 'product_source_synthesis_' . ($synthesis_status ?: 'unknown'); }
     6147        if (!in_array($capability_status, ['available'], true)) { $limitations[] = 'source_capability_profiles_' . ($capability_status ?: 'unknown'); }
     6148        if ($cross_truncated) { $limitations[] = 'cross_source_candidate_set_truncated'; }
     6149        if ($inventory_truncated) { $limitations[] = 'product_source_record_groups_truncated'; }
     6150        if ($partial_status !== 'ok') { $limitations[] = 'partial_overlap_' . ($partial_status ?: 'unknown'); }
     6151        if ($partial_truncated) { $limitations[] = 'partial_overlap_records_truncated'; }
     6152
     6153        $source_count = max(0, (int) ($product_synthesis['source_count_in_scope'] ?? 0));
     6154        $capability_count = max(0, (int) ($product_synthesis['capability_profile_source_count'] ?? 0));
     6155        $complete = !$limitations && $source_count === $capability_count;
     6156
     6157        // Classify the available evidence without making a new counting decision.
     6158        // The exact-match flag is supplied only by the importer's existing path,
     6159        // which has already validated the shared fingerprint and normalized dimensions.
     6160        $candidate_records = max(0, (int) ($cross_source['candidate_record_count'] ?? 0));
     6161        $candidate_sources = max(0, (int) ($cross_source['candidate_source_count'] ?? 0));
     6162        if (is_array($matched_event) && !empty($matched_event['id'])) {
     6163            $decision_classification = 'confirmed_exact_shared_fingerprint_match';
     6164            $decision_basis = 'existing_importer_exact_match_contract';
     6165            $decision_identity_proven = true;
     6166        } elseif (!empty($candidate_set_assessment['multiple_candidate_ambiguity'])) {
     6167            $decision_classification = 'multiple_cross_source_candidates_require_review';
     6168            $decision_basis = 'all_candidates_retained_without_identity_resolution';
     6169            $decision_identity_proven = false;
     6170        } elseif ($candidate_records > 0 || $candidate_sources > 0) {
     6171            $decision_classification = 'possible_cross_source_overlap_requires_review';
     6172            $decision_basis = 'candidate_fingerprint_or_temporal_evidence_only';
     6173            $decision_identity_proven = false;
     6174        } else {
     6175            $decision_classification = 'insufficient_evidence_to_establish_cross_source_overlap';
     6176            $decision_basis = 'no_candidate_found_within_declared_search_bounds';
     6177            $decision_identity_proven = false;
     6178        }
     6179
     6180        return [
     6181            'version' => '1.0.0',
     6182            'status' => $complete ? 'evidence_inventory_complete_within_declared_bounds' : 'evidence_inventory_partial_or_limited',
     6183            'product_id' => absint($product_id),
     6184            'source_scope' => 'all_persisted_generic_sources_for_product',
     6185            'source_count' => $source_count,
     6186            'capability_profile_source_count' => $capability_count,
     6187            'cross_source_candidate_count' => max(0, (int) ($cross_source['candidate_source_count'] ?? 0)),
     6188            'partial_overlap_source_count' => count((array) ($partial_overlap['profiles'] ?? [])),
     6189            'same_bucket_count_bounds' => $cross_source['identical_bucket_count_range'] ?? ['status' => 'unavailable'],
     6190            'reconciliation_decision' => [
     6191                'classification' => $decision_classification,
     6192                'basis' => $decision_basis,
     6193                'identity_proven' => $decision_identity_proven,
     6194                'summary_authorizes_count_change' => false,
     6195                'candidate_search_truncated' => $cross_truncated,
     6196                'candidate_set_assessment' => $candidate_set_assessment,
     6197                'policy' => 'This classification describes evidence only. It does not independently authorize suppression, adjustment, or rewriting of applied_views.',
     6198            ],
     6199            'unique_view_total' => null,
     6200            'counting_decision' => 'preserve_all_applied_contributions_no_automatic_suppression',
     6201            'limitations' => array_values(array_unique($limitations)),
     6202            'interpretation' => 'This summary combines source inventory, declared capabilities, candidate overlap evidence, and the availability limits of partial-period comparison. It does not prove event identity or calculate a deduplicated unique-view total.',
     6203        ];
     6204    }
     6205
     6206    /**
     6207     * Partial aggregate-period duration comparisons are withheld until source
     6208     * timezone provenance is persisted. Naive datetime strings cannot safely be
     6209     * converted into shared elapsed-time intervals.
     6210     */
     6211    private function build_generic_partial_overlap_profiles($product_id, $source_id, $period_start, $period_end) {
     6212        // Persisted period strings do not include source timezone provenance.
     6213        // Do not report elapsed seconds or coverage ratios from naive datetimes.
     6214        return [
     6215            'status' => 'unavailable',
     6216            'reason' => 'timezone_basis_not_persisted',
     6217            'incoming_period_seconds' => null,
     6218            'profiles' => [],
     6219            'records_truncated' => false,
     6220            'identity_confidence' => 'unproven_without_shared_event_identity',
     6221            'temporal_comparison' => 'not_performed',
     6222        ];
     6223    }
     6224
     6225    /** Execute the importer while the caller holds the source-origin advisory lock. */
     6226    private function process_generic_historical_view_import_batch($source_id, $origin_key, $retry_checkpoint_override = null, $origin_lock = null) {
     6227        global $wpdb;
     6228
     6229        $source_id = absint($source_id);
     6230        $origin_key = sanitize_key((string) $origin_key);
     6231        if (!$source_id || $origin_key === '') {
     6232            return ['status' => 'invalid_input'];
     6233        }
     6234        if (!$this->generic_advisory_lock_session_is_current($origin_lock)) {
     6235            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6236            return ['status' => 'origin_lock_session_lost'];
     6237        }
     6238
     6239        $state = $this->get_view_source_origin_state($source_id, $origin_key);
     6240        if (empty($state['status']) || $state['status'] !== 'found') {
     6241            return ['status' => 'origin_not_found'];
     6242        }
     6243        // A queued action may outlive a provider's latest discovery inventory.
     6244        // Never let such a stale action process an origin that has since been
     6245        // marked unavailable or is no longer explicitly detected as available.
     6246        if ($state['origin_status'] === 'unavailable' || $state['detection_status'] !== 'available') {
     6247            return ['status' => 'origin_unavailable'];
     6248        }
     6249        if ($state['origin_status'] === 'completed' && $retry_checkpoint_override === null) {
     6250            return ['status' => 'already_completed'];
     6251        }
     6252        // A terminal failure for one record must not stop the main source scan.
     6253        // The failed record remains in the ledger for review, while later records
     6254        // continue importing. Only the independent retry worker is capped at three
     6255        // total attempts for that individual record.
     6256        if (!is_array($state['snapshot']) || empty($state['snapshot']['boundary']) || !is_array($state['checkpoint'])) {
     6257            return ['status' => 'snapshot_not_ready'];
     6258        }
     6259
     6260        $adapter_key = isset($state['adapter_key']) ? sanitize_key($state['adapter_key']) : '';
     6261        $adapter = $this->get_historical_view_adapter($adapter_key);
     6262        if (!$adapter) {
     6263            // A scheduled request may have arrived before provider registration.
     6264            $this->discover_historical_view_adapters();
     6265            $adapter = $this->get_historical_view_adapter($adapter_key);
     6266        }
     6267        if (!$adapter) {
     6268            // Provider plugins may be temporarily inactive, load later than this
     6269            // worker, or briefly fail while assembling their registry. Keep the
     6270            // immutable snapshot/checkpoint untouched and retry discovery later.
     6271            // Normal plugin initialization also rediscovers providers, so a
     6272            // returning adapter can resume automatically without administrator
     6273            // intervention. A replacement adapter still cannot reinterpret the
     6274            // persisted identity because register_view_source_origin() guards it.
     6275            $this->update_view_source_origin_state($source_id, $origin_key, [
     6276                'last_error' => 'The registered historical adapter is temporarily unavailable; automatic discovery will be retried.',
     6277            ], $origin_lock);
     6278            $this->schedule_generic_historical_view_import_batch(21600, $source_id, $origin_key);
     6279            return ['status' => 'adapter_unavailable', 'retry_scheduled' => true];
     6280        }
     6281
     6282        $checkpoint = ($retry_checkpoint_override !== null && is_array($retry_checkpoint_override))
     6283            ? $retry_checkpoint_override
     6284            : $state['checkpoint'];
     6285        $batch_start_checkpoint = $checkpoint;
     6286        try {
     6287            $batch = $adapter->get_product_view_batch($origin_key, $checkpoint, $state['snapshot'], 100);
     6288        } catch (Throwable $exception) {
     6289            $failure = $this->record_generic_historical_view_failure($source_id,
     6290                    $origin_key,
     6291                    'batch:' . substr(hash('sha256', wp_json_encode($checkpoint)), 0, 32),
     6292                    0,
     6293                    $exception->getMessage(),
     6294                    null,
     6295                    8,
     6296                    $origin_lock);
     6297            if (!$failure['blocked']) {
     6298                $this->schedule_generic_historical_view_import_batch($failure['retry_delay'], $source_id, $origin_key);
     6299            }
     6300            return ['status' => $failure['blocked'] ? 'needs_attention' : 'source_read_failed', 'attempts' => $failure['attempts']];
     6301        }
     6302
     6303        $batch_rows_are_valid_list = is_array($batch)
     6304            && isset($batch['rows'])
     6305            && is_array($batch['rows'])
     6306            && array_keys($batch['rows']) === (count($batch['rows']) > 0 ? range(0, count($batch['rows']) - 1) : [])
     6307            && count($batch['rows']) <= 100;
     6308        if ($batch_rows_are_valid_list) {
     6309            foreach ($batch['rows'] as $batch_row) {
     6310                if (!is_array($batch_row)) {
     6311                    $batch_rows_are_valid_list = false;
     6312                    break;
     6313                }
     6314            }
     6315        }
     6316
     6317        $batch_is_valid = is_array($batch)
     6318            && $batch_rows_are_valid_list
     6319            && array_key_exists('has_more', $batch)
     6320            && is_bool($batch['has_more'])
     6321            // The adapter must apply the immutable snapshot boundary in its source query.
     6322            && isset($batch['boundary_applied'])
     6323            && $batch['boundary_applied'] === true;
     6324
     6325        if ($batch_is_valid && $batch['has_more']) {
     6326            $batch_is_valid = !empty($batch['rows'])
     6327                && array_key_exists('next_checkpoint', $batch)
     6328                && (is_array($batch['next_checkpoint']) || is_scalar($batch['next_checkpoint']))
     6329                && $this->is_valid_generic_historical_cursor($batch['next_checkpoint'])
     6330                && wp_json_encode($batch['next_checkpoint']) !== wp_json_encode($checkpoint);
     6331        }
     6332
     6333        if (!$batch_is_valid) {
     6334            $failure = $this->record_generic_historical_view_failure($source_id,
     6335                    $origin_key,
     6336                    'batch:' . substr(hash('sha256', wp_json_encode($checkpoint)), 0, 32),
     6337                    0,
     6338                    'Adapter returned an invalid or non-progressing batch. Expected a sequential list of at most 100 array rows, a boolean has_more, boundary_applied=true, and an advancing next_checkpoint whenever more rows remain.',
     6339                    null,
     6340                    8,
     6341                    $origin_lock);
     6342            if (!$failure['blocked']) {
     6343                $this->schedule_generic_historical_view_import_batch($failure['retry_delay'], $source_id, $origin_key);
     6344            }
     6345            return ['status' => $failure['blocked'] ? 'needs_attention' : 'invalid_batch', 'attempts' => $failure['attempts']];
     6346        }
     6347
     6348        // The source has now returned a structurally valid, snapshot-bounded batch;
     6349        // clear a previous transient batch-read failure before processing its records.
     6350        $batch_failure_cleared = $this->clear_generic_historical_view_failure(
     6351            $source_id,
     6352            $origin_key,
     6353            'batch:' . substr(hash('sha256', wp_json_encode($checkpoint)), 0, 32)
     6354        , $origin_lock);
     6355        if (!$batch_failure_cleared) {
     6356            // Do not consume this batch while its previous batch-level failure
     6357            // remains unresolved in the durable ledger.
     6358            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6359            return ['status' => 'failure_ledger_clear_failed'];
     6360        }
     6361
     6362        $processed = 0;
     6363        $duplicates = 0;
     6364        $failed = 0;
     6365        $skipped = 0;
     6366        $next_checkpoint = $checkpoint;
     6367
     6368        $checkpoint_persistence_failed = false;
     6369        foreach ($batch['rows'] as $record) {
     6370            try {
     6371                $normalized = $adapter->normalize_record($origin_key, $record);
     6372            } catch (Throwable $exception) {
     6373                $normalized = null;
     6374                $normalization_error = $exception->getMessage();
     6375            }
     6376
     6377            // A replay of a saved batch must retry only records whose own retry
     6378            // deadline has arrived. Otherwise one due record could cause blocked
     6379            // or not-yet-due neighbours in the same batch to be retried indefinitely.
     6380            if ($retry_checkpoint_override !== null) {
     6381                if (is_array($normalized)) {
     6382                    $retry_record_key = !empty($normalized['source_record_key'])
     6383                        ? (string) $normalized['source_record_key']
     6384                        : wp_json_encode($record);
     6385                } elseif (is_array($record) && isset($record['source_record_key'])) {
     6386                    $retry_record_key = (string) $record['source_record_key'];
     6387                } else {
     6388                    $retry_record_key = wp_json_encode($record);
     6389                }
     6390                $retry_record_key = substr((string) $retry_record_key, 0, 191);
     6391                $retry_record_hash = hash('sha256', (string) $origin_key . "\0" . $retry_record_key);
     6392                $retry_is_due = statpl_db_get_var(statpl_db_prepare(
     6393                    "SELECT id FROM {$this->view_source_failures_table}
     6394                     WHERE source_id = %d AND source_record_hash = %s
     6395                       AND status = 'retry' AND next_retry_at <= %s
     6396                     LIMIT 1",
     6397                    $source_id,
     6398                    $retry_record_hash,
     6399                    current_time('mysql')
     6400                ));
     6401                if (!empty($wpdb->last_error)) {
     6402                    $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key);
     6403                    return ['status' => 'retry_ledger_lookup_failed', 'processed' => $processed, 'duplicates' => $duplicates];
     6404                }
     6405                if (!$retry_is_due) {
     6406                    continue;
     6407                }
     6408            }
     6409
     6410            if (is_array($normalized) && !empty($normalized['skip'])) {
     6411                // Adapters may explicitly identify a source row as outside their
     6412                // product-view scope (for example, a site-wide analytics row for
     6413                // a blog page). Such rows are not failures and must not block the
     6414                // scan, but their source key and cursor still require validation.
     6415                $skip_record_key = isset($normalized['source_record_key'])
     6416                    ? trim((string) $normalized['source_record_key']) : '';
     6417                $skip_cursor = array_key_exists('cursor', $normalized) ? $normalized['cursor'] : null;
     6418                if ($skip_record_key === '' || strlen($skip_record_key) > 191
     6419                    || !$this->is_valid_generic_historical_cursor($skip_cursor)) {
     6420                    $normalized = null;
     6421                    $normalization_error = 'Adapter returned an invalid explicit skip record.';
     6422                } else {
     6423                    $skip_failure_cleared = $this->clear_generic_historical_view_failure(
     6424                        $source_id,
     6425                        $origin_key,
     6426                        $skip_record_key
     6427                    , $origin_lock, null);
     6428                    if (!$skip_failure_cleared) {
     6429                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6430                        $checkpoint_persistence_failed = true;
     6431                        break;
     6432                    }
     6433                    $next_checkpoint['cursor'] = $skip_cursor;
     6434                    $skipped++;
     6435                    continue;
     6436                }
     6437            }
     6438
     6439            if (!is_array($normalized)) {
     6440                $failed++;
     6441                $failure = $this->record_generic_historical_view_failure(
     6442                    $source_id,
     6443                    $origin_key,
     6444                    (is_array($record) && isset($record['source_record_key'])) ? $record['source_record_key'] : wp_json_encode($record),
     6445                    0,
     6446                    isset($normalization_error) ? $normalization_error : 'Adapter could not normalize a source record.',
     6447                    $batch_start_checkpoint,
     6448                    3
     6449                , $origin_lock);
     6450                if (empty($failure['persisted'])) {
     6451                    // Without a durable failure-ledger entry, do not advance past this record.
     6452                    $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6453                    $checkpoint_persistence_failed = true;
     6454                    break;
     6455                }
     6456                if (!$failure['blocked']) {
     6457                    $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6458                }
     6459                unset($normalization_error);
     6460                continue;
     6461            }
     6462
     6463            $product_id = isset($normalized['product_id']) ? absint($normalized['product_id']) : 0;
     6464            $record_key = isset($normalized['source_record_key']) ? trim((string) $normalized['source_record_key']) : '';
     6465            $raw_views = array_key_exists('views', $normalized) ? $normalized['views'] : null;
     6466            $views_is_valid = is_int($raw_views) && $raw_views >= 0;
     6467            if (is_string($raw_views) && preg_match('/^(0|[1-9]\d*)$/D', $raw_views)) {
     6468                // Do not compare large decimal strings through float: on 64-bit PHP,
     6469                // (float) PHP_INT_MAX rounds to 2^63 and can admit an overflowing value.
     6470                $max_views_string = (string) PHP_INT_MAX;
     6471                $raw_views_length = strlen($raw_views);
     6472                $max_views_length = strlen($max_views_string);
     6473                $views_is_valid = $raw_views_length < $max_views_length
     6474                    || ($raw_views_length === $max_views_length && strcmp($raw_views, $max_views_string) <= 0);
     6475            }
     6476            $views = $views_is_valid ? (int) $raw_views : -1;
     6477            $cursor = array_key_exists('cursor', $normalized) ? $normalized['cursor'] : null;
     6478            $record_type = isset($normalized['record_type']) ? sanitize_key($normalized['record_type']) : 'event';
     6479            $period_start = array_key_exists('period_start', $normalized) && $normalized['period_start'] !== null
     6480                ? (string) $normalized['period_start'] : null;
     6481            $period_end = array_key_exists('period_end', $normalized) && $normalized['period_end'] !== null
     6482                ? (string) $normalized['period_end'] : null;
     6483            $periods_are_valid = $this->is_valid_generic_historical_datetime($period_start)
     6484                && $this->is_valid_generic_historical_datetime($period_end);
     6485            if ($periods_are_valid && $period_start !== null && $period_end !== null) {
     6486                $periods_are_valid = strcmp($period_start, $period_end) <= 0;
     6487            }
     6488
     6489            if (
     6490                !$product_id || get_post_type($product_id) !== 'product'
     6491                || $record_key === '' || strlen($record_key) > 191
     6492                || !$views_is_valid || $views < 0 || !in_array($record_type, ['event', 'aggregate'], true)
     6493                || !$this->is_valid_generic_historical_cursor($cursor)
     6494                || !$periods_are_valid
     6495                || ($record_type === 'aggregate' && ($period_start === null || $period_end === null))
     6496            ) {
     6497                $failed++;
     6498                $failure = $this->record_generic_historical_view_failure(
     6499                    $source_id,
     6500                    $origin_key,
     6501                    $record_key !== '' ? $record_key : wp_json_encode($record),
     6502                    $product_id,
     6503                    'Normalized record failed validation (product, key, views, type or cursor).',
     6504                    $batch_start_checkpoint,
     6505                    3
     6506                , $origin_lock);
     6507                if (empty($failure['persisted'])) {
     6508                    $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6509                    $checkpoint_persistence_failed = true;
     6510                    break;
     6511                }
     6512                if (!$failure['blocked']) {
     6513                    $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6514                }
     6515                unset($normalization_error);
     6516                continue;
     6517            }
     6518
     6519            $record_hash = hash('sha256', $origin_key . "\0" . $record_key);
     6520            $now = current_time('mysql');
     6521            $source_fingerprint = isset($normalized['source_fingerprint'])
     6522                ? strtolower(trim((string) $normalized['source_fingerprint']))
     6523                : '';
     6524            if ($source_fingerprint !== '' && !preg_match('/^[a-f0-9]{64}$/D', $source_fingerprint)) {
     6525                $failed++;
     6526                $failure = $this->record_generic_historical_view_failure(
     6527                    $source_id,
     6528                    $origin_key,
     6529                    $record_key,
     6530                    $product_id,
     6531                    'Adapter supplied an invalid cross-source fingerprint; expected exactly 64 hexadecimal characters.',
     6532                    $batch_start_checkpoint,
     6533                    3
     6534                , $origin_lock);
     6535                if (empty($failure['persisted'])) {
     6536                    // Without a durable failure-ledger entry, do not advance past this record.
     6537                    $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6538                    $checkpoint_persistence_failed = true;
     6539                    break;
     6540                }
     6541                if (!$failure['blocked']) {
     6542                    $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6543                }
     6544                continue;
     6545            }
     6546
     6547            // Cross-plugin reconciliation is deliberately conservative: suppress a
     6548            // second contribution only when both adapters provide the same explicit
     6549            // stable fingerprint AND all normalized event dimensions agree exactly.
     6550            // A fingerprint-scoped advisory lock also serializes the no-existing-row
     6551            // case, which SELECT ... FOR UPDATE alone cannot reliably protect.
     6552            $reconciliation_lock = 'statpl_xr_' . substr(hash('sha256', $wpdb->prefix . ':' . $source_fingerprint), 0, 48);
     6553            $reconciliation_lock_state = null;
     6554            // Aggregate fingerprints derived from product/date/count are not shared
     6555            // event identities. They must never drive exact cross-source suppression.
     6556            // Keep exact fingerprint reconciliation restricted to individual events.
     6557            $has_exact_event_fingerprint = $source_fingerprint !== '' && $record_type === 'event';
     6558            if ($has_exact_event_fingerprint) {
     6559                $reconciliation_lock_state = $this->acquire_generic_advisory_lock($reconciliation_lock);
     6560                if (empty($reconciliation_lock_state['acquired'])) {
     6561                    $this->schedule_generic_historical_view_import_batch(30, $source_id, $origin_key);
     6562                    return ['status' => 'cross_source_lock_busy', 'processed' => $processed, 'duplicates' => $duplicates];
     6563                }
     6564                if (!$this->generic_advisory_lock_session_is_current($reconciliation_lock_state)) {
     6565                    $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6566                    return ['status' => 'cross_source_lock_session_lost', 'processed' => $processed, 'duplicates' => $duplicates];
     6567                }
     6568            }
     6569
     6570            $transaction_open = false;
     6571            try {
     6572                if (statpl_db_query('START TRANSACTION') === false) {
     6573                    $failed++;
     6574                    $transaction_error = (string) $wpdb->last_error;
     6575                    $failure = $this->record_generic_historical_view_failure(
     6576                        $source_id,
     6577                        $origin_key,
     6578                        $record_key,
     6579                        $product_id,
     6580                        'Could not start atomic source-event transaction: ' . $transaction_error,
     6581                        $batch_start_checkpoint,
     6582                        3
     6583                    , $origin_lock);
     6584                    if (empty($failure['persisted'])) {
     6585                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6586                        $checkpoint_persistence_failed = true;
     6587                    break;
     6588                    }
     6589                    if (!$failure['blocked']) {
     6590                        $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6591                    }
     6592                    continue;
     6593                }
     6594                $transaction_open = true;
     6595                $matched_event = null;
     6596            $possible_overlap_event = null;
     6597            $possible_overlap_events = [];
     6598            $possible_overlap_candidates_truncated = false;
     6599            $possible_overlap_fingerprint_candidates_observed = 0;
     6600            $possible_overlap_temporal_candidates_observed = 0;
     6601            $possible_overlap_candidate_count_observed = 0;
     6602            $possible_overlap_candidate_count_is_lower_bound = false;
     6603            $possible_overlap_reason_code = '';
     6604            if ($has_exact_event_fingerprint) {
     6605                $matched_event = statpl_db_get_row(statpl_db_prepare(
     6606                    "SELECT id, source_id, product_id, record_type, observed_views, period_start, period_end, applied_views
     6607                     FROM {$this->view_source_events_table}
     6608                     WHERE source_fingerprint = %s
     6609                       AND source_id <> %d
     6610                       AND product_id = %d
     6611                       AND record_type = %s
     6612                       AND observed_views = %d
     6613                       AND ((period_start IS NULL AND %s = '') OR period_start = %s)
     6614                       AND ((period_end IS NULL AND %s = '') OR period_end = %s)
     6615                     ORDER BY id ASC LIMIT 1 FOR UPDATE",
     6616                    $source_fingerprint,
     6617                    $source_id,
     6618                    $product_id,
     6619                    $record_type,
     6620                    $views,
     6621                    (string) $period_start,
     6622                    $period_start,
     6623                    (string) $period_end,
     6624                    $period_end
     6625                ), ARRAY_A);
     6626                if (!empty($wpdb->last_error)) {
     6627                    $lookup_error = $wpdb->last_error;
     6628                    if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     6629                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6630                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     6631                    }
     6632                    $failed++;
     6633                    $failure = $this->record_generic_historical_view_failure(
     6634                        $source_id,
     6635                        $origin_key,
     6636                        $record_key,
     6637                        $product_id,
     6638                        'Cross-source reconciliation lookup failed: ' . $lookup_error,
     6639                        $batch_start_checkpoint,
     6640                        3
     6641                    , $origin_lock);
     6642                    if (empty($failure['persisted'])) {
     6643                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6644                        $checkpoint_persistence_failed = true;
     6645                    break;
     6646                    }
     6647                    if (!$failure['blocked']) {
     6648                        $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6649                    }
     6650                    continue;
     6651                }
     6652                if (!$matched_event) {
     6653                    // Same explicit fingerprint with different normalized dimensions is
     6654                    // evidence of a possible/partial overlap, not proof of a duplicate.
     6655                    // Preserve and count this contribution, but retain an auditable
     6656                    // candidate link so a source-specific reconciliation rule can review it.
     6657                    $fingerprint_candidate_result = $this->get_generic_reconciliation_candidates_by_source(
     6658                        'fingerprint',
     6659                        ['fingerprint' => $source_fingerprint, 'source_id' => $source_id],
     6660                        25
     6661                    );
     6662                    $possible_overlap_events = $fingerprint_candidate_result['events'];
     6663                    $possible_overlap_fingerprint_candidates_observed = (int) $fingerprint_candidate_result['observed'];
     6664                    if ($fingerprint_candidate_result['status'] === 'error') {
     6665                        $lookup_error = $fingerprint_candidate_result['error'];
     6666                        if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     6667                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6668                            return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     6669                        }
     6670                        $failed++;
     6671                        $failure = $this->record_generic_historical_view_failure(
     6672                            $source_id, $origin_key, $record_key, $product_id,
     6673                            'Possible-overlap reconciliation lookup failed: ' . $lookup_error,
     6674                            $batch_start_checkpoint, 3, $origin_lock
     6675                        );
     6676                        if (empty($failure['persisted'])) {
     6677                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6678                            $checkpoint_persistence_failed = true;
     6679                            break;
     6680                        }
     6681                        if (!$failure['blocked']) {
     6682                            $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6683                        }
     6684                        continue;
     6685                    }
     6686                    if (!empty($fingerprint_candidate_result['truncated'])) {
     6687                        $possible_overlap_candidates_truncated = true;
     6688                        $possible_overlap_candidate_count_is_lower_bound = true;
     6689                    }
     6690                    if (!empty($possible_overlap_events)) {
     6691                        $possible_overlap_event = $possible_overlap_events[0];
     6692                        $possible_overlap_reason_code = 'shared_fingerprint_dimension_mismatch';
     6693                    }
     6694                    if (!empty($wpdb->last_error)) {
     6695                        $lookup_error = $wpdb->last_error;
     6696                        if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     6697                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6698                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     6699                    }
     6700                        $failed++;
     6701                        $failure = $this->record_generic_historical_view_failure(
     6702                            $source_id,
     6703                            $origin_key,
     6704                            $record_key,
     6705                            $product_id,
     6706                            'Possible-overlap reconciliation lookup failed: ' . $lookup_error,
     6707                            $batch_start_checkpoint,
     6708                            3
     6709                        , $origin_lock);
     6710                        if (empty($failure['persisted'])) {
     6711                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6712                            $checkpoint_persistence_failed = true;
     6713                    break;
     6714                        }
     6715                        if (!$failure['blocked']) {
     6716                            $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6717                        }
     6718                        continue;
     6719                    }
     6720                }
     6721            }
     6722            // Cross-source temporal overlap is retained as evidence only. This
     6723            // includes aggregate/event, aggregate/aggregate, and event/event pairs.
     6724            // Event timestamps can reveal strong candidate relationships even when
     6725            // providers do not share an event ID; temporal proximity alone still does
     6726            // not prove identity, so this discovery step never suppresses counts.
     6727            if (
     6728                !$matched_event
     6729                && in_array($record_type, ['aggregate', 'event'], true)
     6730                && $period_start !== null
     6731                && $period_end !== null
     6732            ) {
     6733                $temporal_candidate_result = $this->get_generic_reconciliation_candidates_by_source(
     6734                    'temporal',
     6735                    ['source_id' => $source_id, 'product_id' => $product_id, 'period_start' => $period_start, 'period_end' => $period_end],
     6736                    25
     6737                );
     6738                $temporal_overlap_events = $temporal_candidate_result['events'];
     6739                if ($temporal_candidate_result['status'] === 'error') {
     6740                    $lookup_error = $temporal_candidate_result['error'];
     6741                    if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     6742                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6743                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     6744                    }
     6745                    $failed++;
     6746                    $failure = $this->record_generic_historical_view_failure(
     6747                        $source_id, $origin_key, $record_key, $product_id,
     6748                        'Cross-source temporal-overlap lookup failed: ' . $lookup_error,
     6749                        $batch_start_checkpoint, 3, $origin_lock
     6750                    );
     6751                    if (empty($failure['persisted'])) {
     6752                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6753                        $checkpoint_persistence_failed = true;
     6754                        break;
     6755                    }
     6756                    if (!$failure['blocked']) {
     6757                        $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6758                    }
     6759                    continue;
     6760                }
     6761                if (!empty($wpdb->last_error)) {
     6762                    $lookup_error = $wpdb->last_error;
     6763                    if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     6764                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6765                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     6766                    }
     6767                    $failed++;
     6768                    $failure = $this->record_generic_historical_view_failure(
     6769                        $source_id,
     6770                        $origin_key,
     6771                        $record_key,
     6772                        $product_id,
     6773                        'Cross-source temporal-overlap lookup failed: ' . $lookup_error,
     6774                        $batch_start_checkpoint,
     6775                        3
     6776                    , $origin_lock);
     6777                    if (empty($failure['persisted'])) {
     6778                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6779                        $checkpoint_persistence_failed = true;
     6780                        break;
     6781                    }
     6782                    if (!$failure['blocked']) {
     6783                        $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6784                    }
     6785                    continue;
     6786                }
     6787                $possible_overlap_temporal_candidates_observed = (int) $temporal_candidate_result['observed'];
     6788                if (!empty($temporal_candidate_result['truncated'])) {
     6789                    $possible_overlap_candidates_truncated = true;
     6790                    $possible_overlap_candidate_count_is_lower_bound = true;
     6791                }
     6792
     6793                $candidate_discovery_by_id = [];
     6794                foreach ($possible_overlap_events as $candidate_event) {
     6795                    $candidate_id = isset($candidate_event['id']) ? (int) $candidate_event['id'] : 0;
     6796                    if ($candidate_id > 0) {
     6797                        $candidate_discovery_by_id[$candidate_id]['fingerprint'] = true;
     6798                    }
     6799                }
     6800                foreach ($temporal_overlap_events as $candidate_event) {
     6801                    $candidate_id = isset($candidate_event['id']) ? (int) $candidate_event['id'] : 0;
     6802                    if ($candidate_id > 0) {
     6803                        $candidate_discovery_by_id[$candidate_id]['temporal'] = true;
     6804                    }
     6805                }
     6806
     6807                $candidate_events_by_id = [];
     6808                foreach (array_merge($possible_overlap_events, $temporal_overlap_events) as $candidate_event) {
     6809                    $candidate_id = isset($candidate_event['id']) ? (int) $candidate_event['id'] : 0;
     6810                    if ($candidate_id > 0) {
     6811                        $candidate_event['_discovered_by'] = array_keys($candidate_discovery_by_id[$candidate_id] ?? []);
     6812                        $candidate_events_by_id[$candidate_id] = $candidate_event;
     6813                    }
     6814                }
     6815                $possible_overlap_events = array_values($candidate_events_by_id);
     6816                usort($possible_overlap_events, static function ($left, $right) {
     6817                    $left_sources = isset($left['_discovered_by']) ? $left['_discovered_by'] : [];
     6818                    $right_sources = isset($right['_discovered_by']) ? $right['_discovered_by'] : [];
     6819                    $left_rank = count($left_sources) > 1 ? 0 : (in_array('fingerprint', $left_sources, true) ? 1 : 2);
     6820                    $right_rank = count($right_sources) > 1 ? 0 : (in_array('fingerprint', $right_sources, true) ? 1 : 2);
     6821                    if ($left_rank !== $right_rank) {
     6822                        return $left_rank <=> $right_rank;
     6823                    }
     6824                    return (int) $left['id'] <=> (int) $right['id'];
     6825                });
     6826                $possible_overlap_candidate_count_observed = count($possible_overlap_events);
     6827                if (count($possible_overlap_events) > 25) {
     6828                    $possible_overlap_candidates_truncated = true;
     6829                    $possible_overlap_candidate_count_is_lower_bound = true;
     6830                    $possible_overlap_events = array_slice($possible_overlap_events, 0, 25);
     6831                }
     6832                if (!empty($possible_overlap_events)) {
     6833                    $possible_overlap_event = $possible_overlap_events[0];
     6834                }
     6835                if ($possible_overlap_event) {
     6836                    $has_fingerprint_candidate = false;
     6837                    $has_cross_type_candidate = false;
     6838                    $has_same_type_event_candidate = false;
     6839                    foreach ($possible_overlap_events as $candidate_event) {
     6840                        $discovery = isset($candidate_event['_discovered_by']) ? $candidate_event['_discovered_by'] : [];
     6841                        if (in_array('fingerprint', $discovery, true)) {
     6842                            $has_fingerprint_candidate = true;
     6843                        }
     6844                        if (
     6845                            in_array('temporal', $discovery, true)
     6846                            && isset($candidate_event['record_type'])
     6847                            && $candidate_event['record_type'] !== $record_type
     6848                        ) {
     6849                            $has_cross_type_candidate = true;
     6850                        }
     6851                        if (
     6852                            in_array('temporal', $discovery, true)
     6853                            && $record_type === 'event'
     6854                            && isset($candidate_event['record_type'])
     6855                            && $candidate_event['record_type'] === 'event'
     6856                        ) {
     6857                            $has_same_type_event_candidate = true;
     6858                        }
     6859                    }
     6860                    if ($has_fingerprint_candidate) {
     6861                        $possible_overlap_reason_code = 'shared_fingerprint_dimension_mismatch';
     6862                    } elseif ($has_cross_type_candidate) {
     6863                        $possible_overlap_reason_code = 'aggregate_event_temporal_overlap';
     6864                    } elseif ($has_same_type_event_candidate) {
     6865                        $possible_overlap_reason_code = 'event_timestamp_overlap';
     6866                    } elseif ($possible_overlap_reason_code === '') {
     6867                        $possible_overlap_reason_code = 'overlapping_aggregate_periods';
     6868                    }
     6869                }
     6870            }
     6871
     6872            if (empty($temporal_overlap_events) && !empty($possible_overlap_events)) {
     6873                foreach ($possible_overlap_events as &$candidate_event) {
     6874                    $candidate_event['_discovered_by'] = ['fingerprint'];
     6875                }
     6876                unset($candidate_event);
     6877                $possible_overlap_candidate_count_observed = count($possible_overlap_events);
     6878                if (!empty($possible_overlap_events)) {
     6879                    $possible_overlap_event = $possible_overlap_events[0];
     6880                }
     6881            }
     6882
     6883            // Optional source-specific analysis enriches the audit trail for partial
     6884            // overlaps. It is deliberately advisory: an adapter cannot silently change
     6885            // the counting policy or suppress a contribution through this extension.
     6886            $adapter_overlap_evidence = [];
     6887            $adapter_evidence_bytes = 0;
     6888            $adapter_evidence_total_limit = 65536;
     6889            $adapter_evidence_candidate_limit = 4096;
     6890            $adapter_evidence_omitted = 0;
     6891            if (!$matched_event && $possible_overlap_event && method_exists($adapter, 'get_reconciliation_evidence')) {
     6892                $overlap_candidates = !empty($possible_overlap_events)
     6893                    ? $possible_overlap_events
     6894                    : [$possible_overlap_event];
     6895                foreach ($overlap_candidates as $candidate_event) {
     6896                    try {
     6897                        $adapter_evidence = $adapter->get_reconciliation_evidence(
     6898                            $origin_key,
     6899                            $normalized,
     6900                            $candidate_event
     6901                        );
     6902                    } catch (Throwable $exception) {
     6903                        $adapter_evidence = [
     6904                            'analysis_status' => 'failed',
     6905                            'error' => substr(sanitize_text_field($exception->getMessage()), 0, 500),
     6906                        ];
     6907                    }
     6908
     6909                    if (!is_array($adapter_evidence)) {
     6910                        $adapter_evidence = ['analysis_status' => 'invalid_result'];
     6911                    }
     6912                    $encoded_adapter_evidence = wp_json_encode($adapter_evidence);
     6913                    $candidate_evidence_size = is_string($encoded_adapter_evidence) ? strlen($encoded_adapter_evidence) : 0;
     6914                    if (!is_string($encoded_adapter_evidence)) {
     6915                        $adapter_evidence = [
     6916                            'analysis_status' => 'rejected',
     6917                            'reason' => 'Evidence was not JSON serializable.',
     6918                        ];
     6919                        $candidate_evidence_size = strlen(wp_json_encode($adapter_evidence));
     6920                    } elseif ($candidate_evidence_size > $adapter_evidence_candidate_limit) {
     6921                        $adapter_evidence = [
     6922                            'analysis_status' => 'rejected',
     6923                            'reason' => 'Evidence exceeded the 4 KiB per-candidate limit.',
     6924                            'original_bytes' => $candidate_evidence_size,
     6925                        ];
     6926                        $candidate_evidence_size = strlen(wp_json_encode($adapter_evidence));
     6927                    }
     6928                    if ($adapter_evidence_bytes + $candidate_evidence_size > $adapter_evidence_total_limit) {
     6929                        $adapter_evidence_omitted++;
     6930                        $adapter_evidence = [
     6931                            'analysis_status' => 'omitted_budget',
     6932                            'reason' => 'The 64 KiB total adapter-evidence budget was reached.',
     6933                        ];
     6934                        $candidate_evidence_size = strlen(wp_json_encode($adapter_evidence));
     6935                    }
     6936                    $adapter_evidence_bytes += $candidate_evidence_size;
     6937                    $candidate_start = isset($candidate_event['period_start']) ? (string) $candidate_event['period_start'] : '';
     6938                    $candidate_end = isset($candidate_event['period_end']) ? (string) $candidate_event['period_end'] : '';
     6939                    $incoming_start = (string) $period_start;
     6940                    $incoming_end = (string) $period_end;
     6941                    $incoming_has_period = $incoming_start !== '' && $incoming_end !== '';
     6942                    $candidate_has_period = $candidate_start !== '' && $candidate_end !== '';
     6943                    // Exact string equality remains a representation-level signal;
     6944                    // it is not a timezone-safe temporal comparison.
     6945                    $same_start = $incoming_has_period && $candidate_has_period && $incoming_start === $candidate_start;
     6946                    $same_end = $incoming_has_period && $candidate_has_period && $incoming_end === $candidate_end;
     6947                    $incoming_temporal_basis = $this->classify_reconciliation_temporal_basis(
     6948                        $adapter_key,
     6949                        $origin_key,
     6950                        $record_type
     6951                    );
     6952                    $candidate_adapter_key = isset($candidate_event['candidate_adapter_key'])
     6953                        ? sanitize_key((string) $candidate_event['candidate_adapter_key']) : '';
     6954                    $candidate_record_key = isset($candidate_event['source_record_key'])
     6955                        ? (string) $candidate_event['source_record_key'] : '';
     6956                    $candidate_origin_key = '';
     6957                    if ($candidate_adapter_key === 'pulseviews') {
     6958                        if (strpos($candidate_record_key, 'pulseviews_day_') === 0) {
     6959                            $candidate_origin_key = 'daily_aggregates';
     6960                        } elseif (strpos($candidate_record_key, 'pulseviews_view_') === 0) {
     6961                            $candidate_origin_key = 'recent_raw_views';
     6962                        }
     6963                    }
     6964                    $candidate_temporal_basis = $this->classify_reconciliation_temporal_basis(
     6965                        $candidate_adapter_key,
     6966                        $candidate_origin_key,
     6967                        isset($candidate_event['record_type']) ? $candidate_event['record_type'] : ''
     6968                    );
     6969                    $periods_intersect = false;
     6970                    $temporal_relation = ($incoming_has_period && $candidate_has_period)
     6971                        ? 'not_compared_timezone_basis_unverified'
     6972                        : 'insufficient_temporal_data';
     6973                    $incoming_precision = 'unknown';
     6974                    if ($record_type === 'aggregate') {
     6975                        $incoming_precision = 'aggregate_period';
     6976                    } elseif ($adapter_key === 'statify') {
     6977                        $incoming_precision = 'calendar_day';
     6978                    } elseif (in_array($adapter_key, ['burst_statistics', 'independent_analytics'], true)) {
     6979                        $incoming_precision = 'seconds';
     6980                    } elseif ($adapter_key === 'pulseviews') {
     6981                        $incoming_precision = $origin_key === 'daily_aggregates' ? 'calendar_day' : 'seconds';
     6982                    } elseif ($record_type === 'event' && $incoming_start !== '' && $incoming_start === $incoming_end) {
     6983                        $incoming_precision = 'source_event_timestamp_unverified';
     6984                    }
     6985                    $candidate_precision = (isset($candidate_event['record_type']) && $candidate_event['record_type'] === 'aggregate')
     6986                        ? 'aggregate_period'
     6987                        : ((isset($candidate_event['record_type']) && $candidate_event['record_type'] === 'event' && $candidate_start !== '' && $candidate_start === $candidate_end)
     6988                            ? 'stored_event_timestamp_unverified'
     6989                            : 'unknown');
     6990                    $incoming_granularity = $this->classify_reconciliation_temporal_granularity(
     6991                        (string) $record_type,
     6992                        $incoming_start,
     6993                        $incoming_end,
     6994                        $incoming_precision
     6995                    );
     6996                    $candidate_granularity = $this->classify_reconciliation_temporal_granularity(
     6997                        isset($candidate_event['record_type']) ? (string) $candidate_event['record_type'] : '',
     6998                        $candidate_start,
     6999                        $candidate_end,
     7000                        $candidate_precision
     7001                    );
     7002                    $same_temporal_granularity = $incoming_granularity['granularity'] !== 'unknown'
     7003                        && $incoming_granularity['granularity'] === $candidate_granularity['granularity'];
     7004                    $temporal_granularity_relation = $same_temporal_granularity
     7005                        ? 'same_granularity_temporal_evidence_only'
     7006                        : (($incoming_granularity['granularity'] !== 'unknown' && $candidate_granularity['granularity'] !== 'unknown')
     7007                            ? 'different_temporal_granularity'
     7008                            : 'insufficient_temporal_granularity');
     7009
     7010                    $candidate_rank = count($adapter_overlap_evidence) + 1;
     7011                    $adapter_overlap_evidence[] = [
     7012                        'candidate_event_id' => (int) $candidate_event['id'],
     7013                        'candidate_rank' => $candidate_rank,
     7014                        'is_primary_candidate' => $candidate_rank === 1
     7015                            && (int) $candidate_event['id'] === (int) $possible_overlap_event['id'],
     7016                        'discovered_by' => isset($candidate_event['_discovered_by']) ? $candidate_event['_discovered_by'] : [],
     7017                        'adapter_key' => $adapter_key,
     7018                        'comparison' => [
     7019                            'identity_assessment' => [
     7020                                'candidate_discovery_signals' => isset($candidate_event['_discovered_by'])
     7021                                    ? array_values((array) $candidate_event['_discovered_by'])
     7022                                    : [],
     7023                                'same_explicit_fingerprint_signal' => in_array(
     7024                                    'fingerprint',
     7025                                    (array) ($candidate_event['_discovered_by'] ?? []),
     7026                                    true
     7027                                ) && $source_fingerprint !== '',
     7028                                'classification' => (
     7029                                    in_array('fingerprint', (array) ($candidate_event['_discovered_by'] ?? []), true)
     7030                                    && $source_fingerprint !== ''
     7031                                ) ? 'shared_fingerprint_candidate_requires_contract_and_dimension_validation' : 'temporal_or_other_candidate_without_shared_identity',
     7032                                'identity_proven' => false,
     7033                                'automatic_suppression_authorized' => false,
     7034                                'reason' => 'Candidate discovery by a matching fingerprint is a review signal only. Identity is confirmed only by the separate exact-match path, which also requires event type and all normalized dimensions to agree.',
     7035                            ],
     7036                            'incoming' => [
     7037                                'source_id' => (int) $source_id,
     7038                                'origin_key' => (string) $origin_key,
     7039                                'product_id' => (int) $product_id,
     7040                                'record_type' => (string) $record_type,
     7041                                'observed_views' => (int) $views,
     7042                                'period_start' => $incoming_start,
     7043                                'period_end' => $incoming_end,
     7044                                'temporal_basis' => $incoming_temporal_basis,
     7045                            ],
     7046                            'candidate' => [
     7047                                'source_id' => isset($candidate_event['source_id']) ? (int) $candidate_event['source_id'] : 0,
     7048                                'source_key' => isset($candidate_event['candidate_source_key']) ? (string) $candidate_event['candidate_source_key'] : null,
     7049                                'adapter_key' => isset($candidate_event['candidate_adapter_key']) ? (string) $candidate_event['candidate_adapter_key'] : null,
     7050                                'product_id' => isset($candidate_event['product_id']) ? (int) $candidate_event['product_id'] : 0,
     7051                                'record_type' => isset($candidate_event['record_type']) ? (string) $candidate_event['record_type'] : '',
     7052                                'observed_views' => isset($candidate_event['observed_views']) ? (int) $candidate_event['observed_views'] : 0,
     7053                                'applied_views' => isset($candidate_event['applied_views']) ? (int) $candidate_event['applied_views'] : 0,
     7054                                'period_start' => $candidate_start,
     7055                                'period_end' => $candidate_end,
     7056                                'temporal_basis' => $candidate_temporal_basis,
     7057                            ],
     7058                            'temporal_analysis' => [
     7059                                'incoming_precision' => $incoming_precision,
     7060                                'candidate_precision' => $candidate_precision,
     7061                                'relation' => $temporal_relation,
     7062                                'incoming_basis' => $incoming_temporal_basis,
     7063                                'candidate_basis' => $candidate_temporal_basis,
     7064                                'absolute_time_comparison_performed' => false,
     7065                                'incoming_granularity' => $incoming_granularity,
     7066                                'candidate_granularity' => $candidate_granularity,
     7067                                'granularity_relation' => $temporal_granularity_relation,
     7068                                'same_granularity_is_identity_proof' => false,
     7069                                'same_granularity_establishes_timezone_compatibility' => false,
     7070                                'same_period_bounds' => $same_start && $same_end,
     7071                                'same_instant_bounds' => false,
     7072                                'same_event_datetime_string_representation' => $same_start && $same_end
     7073                                    && $record_type === 'event'
     7074                                    && isset($candidate_event['record_type']) && $candidate_event['record_type'] === 'event',
     7075                                'same_instant_is_proven' => false,
     7076                                'interpretation' => 'Temporal similarity is evidence of possible overlap only; it does not establish event identity.',
     7077                            ],
     7078                            'signals' => [
     7079                                'same_product' => isset($candidate_event['product_id']) && (int) $candidate_event['product_id'] === (int) $product_id,
     7080                                'same_record_type' => isset($candidate_event['record_type']) && (string) $candidate_event['record_type'] === (string) $record_type,
     7081                                'same_period_start' => $candidate_start !== '' && $candidate_start === $incoming_start,
     7082                                'same_period_end' => $candidate_end !== '' && $candidate_end === $incoming_end,
     7083                                'same_observed_view_count' => isset($candidate_event['observed_views']) && (int) $candidate_event['observed_views'] === (int) $views,
     7084                                'same_explicit_fingerprint_candidate_signal' => in_array('fingerprint', (array) ($candidate_event['_discovered_by'] ?? []), true) && $source_fingerprint !== '',
     7085                                'periods_intersect' => null,
     7086                                'same_period_string_representation' => $same_start && $same_end,
     7087                                'period_string_equality_is_temporal_proof' => false,
     7088                            ],
     7089                        ],
     7090                        'evidence_contract_version' => '1.7.0',
     7091                        'evidence' => $adapter_evidence,
     7092                    ];
     7093                }
     7094            }
     7095
     7096            if ($adapter_evidence_omitted > 0 && !empty($adapter_overlap_evidence)) {
     7097                $adapter_overlap_evidence[0]['evidence_budget_summary'] = [
     7098                    'total_limit_bytes' => $adapter_evidence_total_limit,
     7099                    'candidate_limit_bytes' => $adapter_evidence_candidate_limit,
     7100                    'omitted_candidates' => $adapter_evidence_omitted,
     7101                    'stored_evidence_bytes' => $adapter_evidence_bytes,
     7102                ];
     7103            }
     7104
     7105            $applied_views = $matched_event ? 0 : $views;
     7106            $inserted = statpl_db_insert($this->view_source_events_table, [
     7107                'source_id' => $source_id,
     7108                'source_record_key' => $record_key,
     7109                'source_record_hash' => $record_hash,
     7110                'product_id' => $product_id,
     7111                'record_type' => $record_type,
     7112                'period_start' => $period_start,
     7113                'period_end' => $period_end,
     7114                'observed_views' => $views,
     7115                'applied_views' => $applied_views,
     7116                'source_fingerprint' => $has_exact_event_fingerprint ? $source_fingerprint : null,
     7117                'status' => $matched_event ? 'reconciled' : ($possible_overlap_event ? 'possible_overlap' : 'applied'),
     7118                'created_at' => $now,
     7119                'updated_at' => $now,
     7120            ]);
     7121
     7122            if ($inserted === 1) {
     7123                $new_event_id = (int) $wpdb->insert_id;
     7124                // Build the shared audit context for both exact matches and possible
     7125                // overlaps. The possible-overlap persistence path below also stores
     7126                // these values, so they must never depend on the exact-match branch.
     7127                $cross_source_assessment = null;
     7128                $partial_overlap_profiles = null;
     7129                $product_source_synthesis = null;
     7130                $product_reconciliation_summary = null;
     7131                if ($matched_event || $possible_overlap_event) {
     7132                    $audit_candidates = !empty($possible_overlap_events)
     7133                        ? $possible_overlap_events
     7134                        : ($matched_event ? [$matched_event] : [$possible_overlap_event]);
     7135                    $cross_source_assessment = $this->build_generic_cross_source_overlap_assessment(
     7136                        $product_id,
     7137                        $source_id,
     7138                        $adapter_key,
     7139                        $record_type,
     7140                        $period_start,
     7141                        $period_end,
     7142                        $audit_candidates,
     7143                        $possible_overlap_candidates_truncated,
     7144                        $views
     7145                    );
     7146                    $partial_overlap_profiles = $this->build_generic_partial_overlap_profiles(
     7147                        $product_id,
     7148                        $source_id,
     7149                        $period_start,
     7150                        $period_end
     7151                    );
     7152                    $product_source_synthesis = $this->build_generic_product_source_synthesis($product_id);
     7153                    $product_reconciliation_summary = $this->build_generic_product_reconciliation_summary(
     7154                        $product_id,
     7155                        $cross_source_assessment,
     7156                        $partial_overlap_profiles,
     7157                        $product_source_synthesis,
     7158                        $matched_event,
     7159                        [
     7160                            'candidates' => !empty($possible_overlap_events)
     7161                                ? $possible_overlap_events
     7162                                : ($matched_event ? [$matched_event] : ($possible_overlap_event ? [$possible_overlap_event] : [])),
     7163                            'truncated' => $possible_overlap_candidates_truncated,
     7164                            'observed_count' => $possible_overlap_candidate_count_observed,
     7165                        ]
     7166                    );
     7167                }
     7168                if ($matched_event) {
     7169                    $adapter_discovery_evidence_consistency = $this->build_generic_adapter_discovery_evidence_consistency(
     7170                        $candidate_conflict_assessment,
     7171                        $adapter_overlap_evidence
     7172                    );
     7173
     7174                    $evidence = wp_json_encode([
     7175                        'match_type' => 'exact_shared_fingerprint',
     7176                        'source_id' => $source_id,
     7177                        'matched_source_id' => (int) $matched_event['source_id'],
     7178                        'product_id' => $product_id,
     7179                        'record_type' => $record_type,
     7180                        'observed_views' => $views,
     7181                        'period_start' => $period_start,
     7182                        'period_end' => $period_end,
     7183                        'source_fingerprint' => $source_fingerprint,
     7184                        'cross_source_assessment' => $cross_source_assessment,
     7185                        'partial_overlap_profiles' => $partial_overlap_profiles,
     7186                        'product_source_synthesis' => $product_source_synthesis,
     7187                        'product_reconciliation_summary' => $product_reconciliation_summary,
     7188                        'candidate_set_assessment' => $this->build_generic_candidate_set_assessment(
     7189                            !empty($possible_overlap_events) ? $possible_overlap_events : [$matched_event],
     7190                            $possible_overlap_candidates_truncated,
     7191                            $possible_overlap_candidate_count_observed
     7192                        ),
     7193                    ]);
     7194                    $reconciliation_inserted = statpl_db_insert($this->view_source_reconciliation_table, [
     7195                        'event_id' => $new_event_id,
     7196                        'status' => 'matched_exact',
     7197                        'reason_code' => 'shared_fingerprint_exact_match',
     7198                        'reason' => 'A different source supplied the same explicit fingerprint and identical normalized event dimensions. This contribution was retained for audit but not added again.',
     7199                        'matched_event_id' => (int) $matched_event['id'],
     7200                        'evidence' => $evidence,
     7201                        'algorithm_version' => '1.0.0',
     7202                        'created_at' => $now,
     7203                        'updated_at' => $now,
     7204                    ]);
     7205                    $history_inserted = $reconciliation_inserted === 1 ? statpl_db_insert($this->view_source_reconciliation_history_table, [
     7206                        'event_id' => $new_event_id,
     7207                        'previous_status' => null,
     7208                        'new_status' => 'matched_exact',
     7209                        'reason_code' => 'shared_fingerprint_exact_match',
     7210                        'reason' => 'Exact cross-source match using an adapter-provided shared fingerprint and identical normalized dimensions.',
     7211                        'matched_event_id' => (int) $matched_event['id'],
     7212                        'evidence' => $evidence,
     7213                        'algorithm_version' => '1.0.0',
     7214                        'created_at' => $now,
     7215                    ]) : false;
     7216                    if ($reconciliation_inserted !== 1 || $history_inserted !== 1) {
     7217                        if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     7218                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7219                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7220                    }
     7221                        $failed++;
     7222                        $failure = $this->record_generic_historical_view_failure(
     7223                            $source_id,
     7224                            $origin_key,
     7225                            $record_key,
     7226                            $product_id,
     7227                            'Could not persist cross-source reconciliation evidence: ' . $wpdb->last_error,
     7228                            $batch_start_checkpoint,
     7229                            3
     7230                        , $origin_lock);
     7231                        if (empty($failure['persisted'])) {
     7232                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7233                            $checkpoint_persistence_failed = true;
     7234                    break;
     7235                        }
     7236                        if (!$failure['blocked']) {
     7237                            $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     7238                        }
     7239                        continue;
     7240                    }
     7241                }
     7242                if ($possible_overlap_event) {
     7243                    $adapter_dimension_evidence_consistency = $this->build_generic_adapter_dimension_evidence_consistency(
     7244                        $candidate_conflict_assessment,
     7245                        $adapter_overlap_evidence
     7246                    );
     7247                    // Validate that the primary reference, ordered candidate set and
     7248                    // adapter evidence describe the same candidate identities before
     7249                    // persisting the reconciliation record.
     7250                    $primary_candidate_id = (int) $possible_overlap_event['id'];
     7251                    $candidate_ids = array_values(array_unique(array_map(
     7252                        static function ($candidate) {
     7253                            return isset($candidate['id']) ? (int) $candidate['id'] : 0;
     7254                        },
     7255                        !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event]
     7256                    )));
     7257                    $candidate_ids = array_values(array_filter($candidate_ids, static function ($id) {
     7258                        return $id > 0;
     7259                    }));
     7260                    $candidate_list_primary_consistent = in_array($primary_candidate_id, $candidate_ids, true)
     7261                        && !empty($candidate_ids)
     7262                        && $candidate_ids[0] === $primary_candidate_id;
     7263
     7264                    $adapter_candidate_ids = array_values(array_map(
     7265                        static function ($entry) {
     7266                            return isset($entry['candidate_event_id']) ? (int) $entry['candidate_event_id'] : 0;
     7267                        },
     7268                        $adapter_overlap_evidence
     7269                    ));
     7270                    $adapter_evidence_primary_consistent = empty($adapter_overlap_evidence)
     7271                        || (
     7272                            isset($adapter_overlap_evidence[0]['is_primary_candidate'])
     7273                            && $adapter_overlap_evidence[0]['is_primary_candidate'] === true
     7274                            && $adapter_candidate_ids[0] === $primary_candidate_id
     7275                        );
     7276                    $adapter_evidence_order_consistent = true;
     7277                    foreach ($adapter_overlap_evidence as $index => $entry) {
     7278                        if (
     7279                            !isset($entry['candidate_rank'], $entry['candidate_event_id'])
     7280                            || (int) $entry['candidate_rank'] !== $index + 1
     7281                            || (int) $entry['candidate_event_id'] !== (int) ($candidate_ids[$index] ?? 0)
     7282                        ) {
     7283                            $adapter_evidence_order_consistent = false;
     7284                            break;
     7285                        }
     7286                    }
     7287
     7288                    $evidence = wp_json_encode([
     7289                        'match_type' => $possible_overlap_reason_code,
     7290                        'source_id' => $source_id,
     7291                        'matched_source_id' => (int) $possible_overlap_event['source_id'],
     7292                        'matched_event_id' => (int) $possible_overlap_event['id'],
     7293                        'current' => [
     7294                            'product_id' => $product_id,
     7295                            'record_type' => $record_type,
     7296                            'observed_views' => $views,
     7297                            'period_start' => $period_start,
     7298                            'period_end' => $period_end,
     7299                        ],
     7300                        'candidates_truncated' => $possible_overlap_candidates_truncated,
     7301                        'candidate_limit' => 25,
     7302                        'candidate_count_observed' => $possible_overlap_candidate_count_observed,
     7303                        'candidate_count_is_lower_bound' => $possible_overlap_candidate_count_is_lower_bound,
     7304                        'candidate_set_assessment' => $this->build_generic_candidate_set_assessment(
     7305                            !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event],
     7306                            $possible_overlap_candidates_truncated,
     7307                            $possible_overlap_candidate_count_observed
     7308                        ),
     7309                        'candidate_conflict_assessment' => $candidate_conflict_assessment = $this->build_generic_candidate_conflict_assessment(
     7310                            [
     7311                                'product_id' => $product_id,
     7312                                'record_type' => $record_type,
     7313                                'observed_views' => $views,
     7314                                'period_start' => $period_start,
     7315                                'period_end' => $period_end,
     7316                            ],
     7317                            !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event]
     7318                        ),
     7319                        'candidate_evidence_decision' => $candidate_evidence_decision = $this->build_generic_candidate_evidence_decision(
     7320                            $candidate_conflict_assessment,
     7321                            $candidate_set_assessment = $this->build_generic_candidate_set_assessment(
     7322                                !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event],
     7323                                $possible_overlap_candidates_truncated,
     7324                                $possible_overlap_candidate_count_observed
     7325                            ),
     7326                            $possible_overlap_candidates_truncated
     7327                        ),
     7328                        'candidate_audit_consistency' => $this->build_generic_candidate_audit_consistency(
     7329                            $candidate_set_assessment,
     7330                            $candidate_conflict_assessment,
     7331                            $candidate_evidence_decision
     7332                        ),
     7333                        'candidate_reference_consistency' => $this->build_generic_candidate_reference_consistency(
     7334                            $candidate_set_assessment,
     7335                            $candidate_conflict_assessment
     7336                        ),
     7337                        'candidate_discovery_signal_consistency' => $this->build_generic_candidate_discovery_signal_consistency(
     7338                            $candidate_set_assessment,
     7339                            $candidate_conflict_assessment
     7340                        ),
     7341                        'adapter_discovery_evidence_consistency' => $adapter_discovery_evidence_consistency,
     7342                        'adapter_dimension_evidence_consistency' => $adapter_dimension_evidence_consistency,
     7343                        'candidate_consistency' => [
     7344                            'primary_in_candidate_list' => $candidate_list_primary_consistent,
     7345                            'adapter_evidence_primary_consistent' => $adapter_evidence_primary_consistent,
     7346                            'adapter_evidence_order_consistent' => $adapter_evidence_order_consistent,
     7347                            'adapter_discovery_evidence_consistent' => !empty($adapter_discovery_evidence_consistency['consistent']),
     7348                            'adapter_dimension_evidence_consistent' => !empty($adapter_dimension_evidence_consistency['consistent']),
     7349                            'candidate_count' => count($candidate_ids),
     7350                            'adapter_evidence_candidate_count' => count($adapter_candidate_ids),
     7351                            'validation_policy' => 'audit_only_no_automatic_count_suppression',
     7352                        ],
     7353                        'candidate_reconciliation_chain' => $this->build_generic_candidate_reconciliation_chain_assessment(
     7354                            $candidate_set_assessment,
     7355                            $candidate_conflict_assessment,
     7356                            $candidate_evidence_decision,
     7357                            $this->build_generic_candidate_audit_consistency(
     7358                                $candidate_set_assessment,
     7359                                $candidate_conflict_assessment,
     7360                                $candidate_evidence_decision
     7361                            ),
     7362                            [
     7363                                'primary_in_candidate_list' => $candidate_list_primary_consistent,
     7364                                'adapter_evidence_primary_consistent' => $adapter_evidence_primary_consistent,
     7365                                'adapter_evidence_order_consistent' => $adapter_evidence_order_consistent,
     7366                                'adapter_discovery_evidence_consistent' => !empty($adapter_discovery_evidence_consistency['consistent']),
     7367                                'adapter_dimension_evidence_consistent' => !empty($adapter_dimension_evidence_consistency['consistent']),
     7368                                'candidate_count' => count($candidate_ids),
     7369                                'adapter_evidence_candidate_count' => count($adapter_candidate_ids),
     7370                            ],
     7371                            $this->build_generic_candidate_reference_consistency(
     7372                                $candidate_set_assessment,
     7373                                $candidate_conflict_assessment
     7374                            ),
     7375                            $this->build_generic_candidate_discovery_signal_consistency(
     7376                                $candidate_set_assessment,
     7377                                $candidate_conflict_assessment
     7378                            )
     7379                        ),
     7380                        'primary_candidate' => [
     7381                            'event_id' => (int) $possible_overlap_event['id'],
     7382                            'source_id' => (int) $possible_overlap_event['source_id'],
     7383                            'source_key' => isset($possible_overlap_event['candidate_source_key']) ? (string) $possible_overlap_event['candidate_source_key'] : null,
     7384                            'adapter_key' => isset($possible_overlap_event['candidate_adapter_key']) ? (string) $possible_overlap_event['candidate_adapter_key'] : null,
     7385                            'discovered_by' => isset($possible_overlap_event['_discovered_by']) ? $possible_overlap_event['_discovered_by'] : [],
     7386                            'selection_policy' => 'highest_discovery_evidence_then_lowest_event_id',
     7387                        ],
     7388                        'fingerprint_candidates_observed' => $possible_overlap_fingerprint_candidates_observed,
     7389                        'temporal_candidates_observed' => $possible_overlap_temporal_candidates_observed,
     7390                        'candidates' => array_map(static function ($candidate) {
     7391                            return [
     7392                                'event_id' => (int) $candidate['id'],
     7393                                'discovered_by' => isset($candidate['_discovered_by']) ? $candidate['_discovered_by'] : [],
     7394                                'source_id' => (int) $candidate['source_id'],
     7395                                'source_key' => isset($candidate['candidate_source_key']) ? (string) $candidate['candidate_source_key'] : null,
     7396                                'adapter_key' => isset($candidate['candidate_adapter_key']) ? (string) $candidate['candidate_adapter_key'] : null,
     7397                                'product_id' => (int) $candidate['product_id'],
     7398                                'record_type' => (string) $candidate['record_type'],
     7399                                'observed_views' => (int) $candidate['observed_views'],
     7400                                'applied_views' => (int) $candidate['applied_views'],
     7401                                'period_start' => $candidate['period_start'],
     7402                                'period_end' => $candidate['period_end'],
     7403                            ];
     7404                        }, !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event]),
     7405                        'cross_source_assessment' => $cross_source_assessment,
     7406                        'partial_overlap_profiles' => $partial_overlap_profiles,
     7407                        'product_source_synthesis' => $product_source_synthesis,
     7408                        'product_reconciliation_summary' => $product_reconciliation_summary,
     7409                        'source_fingerprint' => $source_fingerprint !== '' ? $source_fingerprint : null,
     7410                        'adapter_overlap_evidence' => $adapter_overlap_evidence,
     7411                        'counting_policy' => 'preserve_both_pending_source_specific_evidence',
     7412                    ]);
     7413                    $reconciliation_inserted = statpl_db_insert($this->view_source_reconciliation_table, [
     7414                        'event_id' => $new_event_id,
     7415                        'status' => 'possible_overlap',
     7416                        'reason_code' => $possible_overlap_reason_code,
     7417                        'reason' => 'A different source contains one or more potentially overlapping records. All matching candidates were retained as evidence; the contribution was preserved and counted because no automatic suppression was justified without stronger source-specific evidence.',
     7418                        'matched_event_id' => (int) $possible_overlap_event['id'],
     7419                        'evidence' => $evidence,
     7420                        'algorithm_version' => '1.6.0',
     7421                        'created_at' => $now,
     7422                        'updated_at' => $now,
     7423                    ]);
     7424                    $history_inserted = $reconciliation_inserted === 1 ? statpl_db_insert($this->view_source_reconciliation_history_table, [
     7425                        'event_id' => $new_event_id,
     7426                        'previous_status' => null,
     7427                        'new_status' => 'possible_overlap',
     7428                        'reason_code' => $possible_overlap_reason_code,
     7429                        'reason' => 'Potential partial overlap recorded for review. Both source contributions remain counted until source-specific evidence justifies adjustment.',
     7430                        'matched_event_id' => (int) $possible_overlap_event['id'],
     7431                        'evidence' => $evidence,
     7432                        'algorithm_version' => '1.6.0',
     7433                        'created_at' => $now,
     7434                    ]) : false;
     7435                    if ($reconciliation_inserted !== 1 || $history_inserted !== 1) {
     7436                        if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     7437                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7438                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7439                    }
     7440                        $failed++;
     7441                        $failure = $this->record_generic_historical_view_failure(
     7442                            $source_id,
     7443                            $origin_key,
     7444                            $record_key,
     7445                            $product_id,
     7446                            'Could not persist possible-overlap reconciliation evidence: ' . $wpdb->last_error,
     7447                            $batch_start_checkpoint,
     7448                            3
     7449                        , $origin_lock);
     7450                        if (empty($failure['persisted'])) {
     7451                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7452                            $checkpoint_persistence_failed = true;
     7453                    break;
     7454                        }
     7455                        if (!$failure['blocked']) {
     7456                            $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     7457                        }
     7458                        continue;
     7459                    }
     7460                }
     7461                if ($applied_views > 0) {
     7462                    $total_result = statpl_db_query(statpl_db_prepare(
     7463                        "INSERT INTO {$this->views_table} (product_id, total_views) VALUES (%d, %d)
     7464                         ON DUPLICATE KEY UPDATE total_views = total_views + VALUES(total_views)",
     7465                        $product_id,
     7466                        $applied_views
     7467                    ));
     7468                    if ($total_result === false) {
     7469                        if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     7470                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7471                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7472                    }
     7473                        $failed++;
     7474                        $failure = $this->record_generic_historical_view_failure(
     7475                            $source_id,
     7476                            $origin_key,
     7477                            $record_key,
     7478                            $product_id,
     7479                            'Could not update product view totals: ' . $wpdb->last_error,
     7480                            $batch_start_checkpoint,
     7481                            3
     7482                        , $origin_lock);
     7483                        if (empty($failure['persisted'])) {
     7484                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7485                            $checkpoint_persistence_failed = true;
     7486                    break;
     7487                        }
     7488                        if (!$failure['blocked']) {
     7489                            $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     7490                        }
     7491                        continue;
     7492                    }
     7493                }
     7494                $origin_session_current = $this->generic_advisory_lock_session_is_current($origin_lock);
     7495                $fingerprint_session_current = !is_array($reconciliation_lock_state)
     7496                    || empty($reconciliation_lock_state['acquired'])
     7497                    || $this->generic_advisory_lock_session_is_current($reconciliation_lock_state);
     7498                if (!$origin_session_current || !$fingerprint_session_current) {
     7499                    // The transaction belongs to the origin-lock connection. A lost
     7500                    // advisory lock does not itself mean the DB connection changed.
     7501                    // Never send ROLLBACK to a transparently reconnected session.
     7502                    if ($transaction_open && !$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     7503                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7504                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7505                    }
     7506                    $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7507                    return [
     7508                        'status' => 'transaction_lock_session_lost',
     7509                        'processed' => $processed,
     7510                        'duplicates' => $duplicates,
     7511                        'failed' => $failed,
     7512                    ];
     7513                }
     7514
     7515                if (statpl_db_query('COMMIT') === false) {
     7516                    $commit_error = (string) $wpdb->last_error;
     7517
     7518                    // A failed COMMIT acknowledgement is ambiguous when WordPress has
     7519                    // transparently reconnected: the server may have committed the
     7520                    // transaction before the client lost its connection. Do not issue
     7521                    // ledger writes on a replacement session or classify the record
     7522                    // as failed. Replaying the same record is safe because the source
     7523                    // event's unique key and transactional totals update are idempotent.
     7524                    $origin_session_after_commit = $this->generic_advisory_lock_session_is_current($origin_lock);
     7525                    $fingerprint_session_after_commit = !is_array($reconciliation_lock_state)
     7526                        || empty($reconciliation_lock_state['acquired'])
     7527                        || $this->generic_advisory_lock_session_is_current($reconciliation_lock_state);
     7528                    if (!$origin_session_after_commit || !$fingerprint_session_after_commit) {
     7529                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7530                        return [
     7531                            'status' => 'commit_outcome_unknown',
     7532                            'processed' => $processed,
     7533                            'duplicates' => $duplicates,
     7534                            'failed' => $failed,
     7535                        ];
     7536                    }
     7537
     7538                    // The original session is still alive, so the failed COMMIT can
     7539                    // be rolled back and recorded as a normal retryable row failure.
     7540                    if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     7541                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7542                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7543                    }
     7544                    $failed++;
     7545                    $failure = $this->record_generic_historical_view_failure(
     7546                        $source_id,
     7547                        $origin_key,
     7548                        $record_key,
     7549                        $product_id,
     7550                        'Could not commit imported source event: ' . $commit_error,
     7551                        $batch_start_checkpoint,
     7552                        3
     7553                    , $origin_lock);
     7554                    if (empty($failure['persisted'])) {
     7555                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7556                        $checkpoint_persistence_failed = true;
     7557                        break;
     7558                    }
     7559                    if (!$failure['blocked']) {
     7560                        $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     7561                    }
     7562                    continue;
     7563                }
     7564                $transaction_open = false;
     7565                $processed++;
     7566            } elseif ($inserted === false && stripos((string) $wpdb->last_error, 'duplicate') !== false) {
     7567                if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     7568                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7569                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7570                    }
     7571                // A repeated source_record_key is idempotent only when the stored
     7572                // normalized payload is identical. A changed payload under the same
     7573                // key signals an adapter/source inconsistency and must not be silently
     7574                // accepted as an ordinary duplicate.
     7575                $existing_event = statpl_db_get_row(statpl_db_prepare(
     7576                    "SELECT id, product_id, record_type, observed_views, period_start, period_end, source_fingerprint, status
     7577                     FROM {$this->view_source_events_table}
     7578                     WHERE source_id = %d AND source_record_hash = %s
     7579                     LIMIT 1",
     7580                    $source_id,
     7581                    $record_hash
     7582                ), ARRAY_A);
     7583                if (!empty($wpdb->last_error) || !is_array($existing_event)) {
     7584                    $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7585                    return ['status' => 'duplicate_verification_failed', 'processed' => $processed, 'duplicates' => $duplicates];
     7586                }
     7587                $existing_period_start = $existing_event['period_start'] !== null ? (string) $existing_event['period_start'] : null;
     7588                $existing_period_end = $existing_event['period_end'] !== null ? (string) $existing_event['period_end'] : null;
     7589                $existing_fingerprint = $existing_event['source_fingerprint'] !== null ? (string) $existing_event['source_fingerprint'] : '';
     7590                $same_existing_payload = (int) $existing_event['product_id'] === $product_id
     7591                    && (string) $existing_event['record_type'] === $record_type
     7592                    && (int) $existing_event['observed_views'] === $views
     7593                    && $existing_period_start === $period_start
     7594                    && $existing_period_end === $period_end
     7595                    && $existing_fingerprint === $source_fingerprint;
     7596                if ($same_existing_payload) {
     7597                    // A retry must not rewrite reconciliation evidence or append
     7598                    // another history row. Verify the already committed audit record
     7599                    // before accepting the source event as a complete duplicate.
     7600                    $existing_event_id = isset($existing_event['id']) ? (int) $existing_event['id'] : 0;
     7601                    $existing_event_status = isset($existing_event['status']) ? (string) $existing_event['status'] : '';
     7602                    if (in_array($existing_event_status, ['possible_overlap', 'reconciled'], true)) {
     7603                        $existing_audit = statpl_db_get_row(statpl_db_prepare(
     7604                            "SELECT status, reason_code, matched_event_id, evidence, algorithm_version
     7605                             FROM {$this->view_source_reconciliation_table}
     7606                             WHERE event_id = %d
     7607                             LIMIT 1",
     7608                            $existing_event_id
     7609                        ), ARRAY_A);
     7610                        if (!empty($wpdb->last_error) || !is_array($existing_audit)) {
     7611                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7612                            return [
     7613                                'status' => 'duplicate_audit_missing_or_unreadable',
     7614                                'processed' => $processed,
     7615                                'duplicates' => $duplicates,
     7616                            ];
     7617                        }
     7618                        $existing_audit_evidence = !empty($existing_audit['evidence'])
     7619                            ? json_decode((string) $existing_audit['evidence'], true)
     7620                            : null;
     7621                        $audit_matches_event = is_array($existing_audit_evidence)
     7622                            && isset($existing_audit_evidence['source_id'])
     7623                            && (int) $existing_audit_evidence['source_id'] === (int) $source_id;
     7624                        if ($existing_event_status === 'possible_overlap') {
     7625                            $audit_matches_event = $audit_matches_event
     7626                                && isset($existing_audit['matched_event_id'], $existing_audit_evidence['primary_candidate']['event_id'])
     7627                                && (int) $existing_audit['matched_event_id'] === (int) $existing_audit_evidence['primary_candidate']['event_id']
     7628                                && (int) $existing_audit_evidence['primary_candidate']['event_id'] > 0;
     7629                        } elseif ($existing_event_status === 'reconciled') {
     7630                            $audit_matches_event = $audit_matches_event
     7631                                && (string) $existing_audit['status'] === 'matched_exact'
     7632                                && isset($existing_audit['matched_event_id'])
     7633                                && (int) $existing_audit['matched_event_id'] > 0;
     7634                        }
     7635                        if (!$audit_matches_event) {
     7636                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7637                            return [
     7638                                'status' => 'duplicate_audit_integrity_mismatch',
     7639                                'processed' => $processed,
     7640                                'duplicates' => $duplicates,
     7641                            ];
     7642                        }
     7643
     7644                        // The current reconciliation row and its latest history
     7645                        // snapshot must agree. History is verified, never synthesized
     7646                        // or repaired during a duplicate retry.
     7647                        $latest_history = statpl_db_get_row(statpl_db_prepare(
     7648                            "SELECT new_status, reason_code, matched_event_id, evidence, algorithm_version
     7649                             FROM {$this->view_source_reconciliation_history_table}
     7650                             WHERE event_id = %d
     7651                             ORDER BY id DESC
     7652                             LIMIT 1",
     7653                            $existing_event_id
     7654                        ), ARRAY_A);
     7655                        if (!empty($wpdb->last_error) || !is_array($latest_history)) {
     7656                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7657                            return [
     7658                                'status' => 'duplicate_audit_history_missing_or_unreadable',
     7659                                'processed' => $processed,
     7660                                'duplicates' => $duplicates,
     7661                            ];
     7662                        }
     7663
     7664                        $history_matches_audit = (string) $latest_history['new_status'] === (string) $existing_audit['status']
     7665                            && (string) $latest_history['reason_code'] === (string) $existing_audit['reason_code']
     7666                            && (int) ($latest_history['matched_event_id'] ?? 0) === (int) ($existing_audit['matched_event_id'] ?? 0)
     7667                            && (string) ($latest_history['evidence'] ?? '') === (string) ($existing_audit['evidence'] ?? '')
     7668                            && (string) $latest_history['algorithm_version'] === (string) $existing_audit['algorithm_version'];
     7669
     7670                        // Validate the recorded transition chain in ascending history
     7671                        // order. The first row is treated as the chain anchor because
     7672                        // older installations may not have retained its predecessor.
     7673                        $history_chain = statpl_db_get_results(statpl_db_prepare(
     7674                            "SELECT id, previous_status, new_status
     7675                             FROM {$this->view_source_reconciliation_history_table}
     7676                             WHERE event_id = %d
     7677                             ORDER BY id ASC",
     7678                            $existing_event_id
     7679                        ), ARRAY_A);
     7680                        if (!empty($wpdb->last_error) || !is_array($history_chain) || empty($history_chain)) {
     7681                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7682                            return [
     7683                                'status' => 'duplicate_audit_history_chain_unreadable',
     7684                                'processed' => $processed,
     7685                                'duplicates' => $duplicates,
     7686                            ];
     7687                        }
     7688
     7689                        $history_chain_consistent = true;
     7690                        $previous_new_status = null;
     7691                        $previous_history_id = 0;
     7692                        foreach ($history_chain as $history_entry) {
     7693                            $history_id = isset($history_entry['id']) ? (int) $history_entry['id'] : 0;
     7694                            $new_status = isset($history_entry['new_status']) ? (string) $history_entry['new_status'] : '';
     7695                            $previous_status = isset($history_entry['previous_status']) && $history_entry['previous_status'] !== null
     7696                                ? (string) $history_entry['previous_status']
     7697                                : null;
     7698
     7699                            if ($history_id <= $previous_history_id || $new_status === '') {
     7700                                $history_chain_consistent = false;
     7701                                break;
     7702                            }
     7703                            if ($previous_new_status !== null && $previous_status !== $previous_new_status) {
     7704                                $history_chain_consistent = false;
     7705                                break;
     7706                            }
     7707                            $previous_new_status = $new_status;
     7708                            $previous_history_id = $history_id;
     7709                        }
     7710
     7711                        if (!$history_chain_consistent) {
     7712                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7713                            return [
     7714                                'status' => 'duplicate_audit_history_transition_chain_mismatch',
     7715                                'processed' => $processed,
     7716                                'duplicates' => $duplicates,
     7717                            ];
     7718                        }
     7719
     7720                        if (!$history_matches_audit) {
     7721                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7722                            return [
     7723                                'status' => 'duplicate_audit_history_integrity_mismatch',
     7724                                'processed' => $processed,
     7725                                'duplicates' => $duplicates,
     7726                            ];
     7727                        }
     7728                    }
     7729                    $duplicates++;
     7730                } else {
     7731                    $failed++;
     7732                    $failure = $this->record_generic_historical_view_failure(
     7733                        $source_id,
     7734                        $origin_key,
     7735                        $record_key,
     7736                        $product_id,
     7737                        'The adapter returned different normalized data for an existing source record key. The original imported event was preserved.',
     7738                        $batch_start_checkpoint,
     7739                        3
     7740                    , $origin_lock);
     7741                    if (empty($failure['persisted'])) {
     7742                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7743                        $checkpoint_persistence_failed = true;
     7744                        break;
     7745                    }
     7746                    if (!$failure['blocked']) {
     7747                        $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     7748                    }
     7749                    continue;
     7750                }
     7751                } else {
     7752                    $error = (string) $wpdb->last_error;
     7753                    if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     7754                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7755                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7756                    }
     7757                    $failed++;
     7758                    $failure = $this->record_generic_historical_view_failure(
     7759                        $source_id,
     7760                        $origin_key,
     7761                        $record_key,
     7762                        $product_id,
     7763                        'Could not persist source event: ' . $error,
     7764                        $batch_start_checkpoint,
     7765                        3
     7766                    , $origin_lock);
     7767                    if (empty($failure['persisted'])) {
     7768                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7769                        $checkpoint_persistence_failed = true;
     7770                    break;
     7771                    }
     7772                    if (!$failure['blocked']) {
     7773                        $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     7774                    }
     7775                    continue;
     7776                }
     7777            } finally {
     7778                if ($transaction_open) {
     7779                    // A changed or unverifiable session means the original transaction
     7780                    // outcome cannot be inspected here. Do not issue transaction control
     7781                    // on a replacement connection; ensure the record is retried instead.
     7782                    if (!$this->generic_db_session_matches_lock($origin_lock)) {
     7783                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7784                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7785                    }
     7786                    if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     7787                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7788                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7789                    }
     7790                }
     7791                if (is_array($reconciliation_lock_state) && !empty($reconciliation_lock_state['acquired'])) {
     7792                    $this->release_generic_advisory_lock($reconciliation_lock_state, 'cross_source_fingerprint');
     7793                }
     7794            }
     7795
     7796            $failure_cleared = $this->clear_generic_historical_view_failure($source_id, $origin_key, $record_key, $origin_lock);
     7797            if ($failure_cleared && is_array($record) && !empty($record['source_record_key']) && (string) $record['source_record_key'] !== $record_key) {
     7798                $failure_cleared = $this->clear_generic_historical_view_failure($source_id, $origin_key, $record['source_record_key'], $origin_lock, null);
     7799            } elseif ($failure_cleared && (!is_array($record) || empty($record['source_record_key']))) {
     7800                $failure_cleared = $this->clear_generic_historical_view_failure($source_id, $origin_key, wp_json_encode($record), $origin_lock, null);
     7801            }
     7802            if (!$failure_cleared) {
     7803                // The event may already be committed. Idempotency makes replay
     7804                // safe, but the cursor must not move beyond this record until
     7805                // its stale failure-ledger entries have been cleared.
     7806                $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7807                $checkpoint_persistence_failed = true;
     7808                break;
     7809            }
     7810            $next_checkpoint['cursor'] = $cursor;
     7811            $next_checkpoint['processed'] = isset($next_checkpoint['processed']) ? (int) $next_checkpoint['processed'] + 1 : 1;
     7812            unset($normalization_error);
     7813        }
     7814
     7815        if ($checkpoint_persistence_failed) {
     7816            // Do not persist the adapter's batch-level next_checkpoint when a
     7817            // row could not be durably represented in the failure ledger. The
     7818            // next main-import attempt must revisit this batch and this row.
     7819            return [
     7820                'status' => 'failure_ledger_persist_failed',
     7821                'processed' => $processed,
     7822                'duplicates' => $duplicates,
     7823                'failed' => $failed,
     7824                'skipped' => $skipped,
     7825            ];
     7826        }
     7827
     7828        // A connection can be transparently re-established after a mid-batch DB outage.
     7829        // Never persist checkpoint/status progress unless this request still owns the
     7830        // original origin lock session that serialized the batch.
     7831        if (!$this->generic_advisory_lock_session_is_current($origin_lock)) {
     7832            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7833            return [
     7834                'status' => 'checkpoint_blocked_origin_lock_session_lost',
     7835                'processed' => $processed,
     7836                'duplicates' => $duplicates,
     7837                'failed' => $failed,
     7838                'skipped' => $skipped,
     7839            ];
     7840        }
     7841
     7842        // Individual bad records have been durably placed in the failure ledger.
     7843        // Continue advancing the main batch checkpoint; a separate replay worker
     7844        // revisits the saved batch checkpoint without blocking later records.
     7845        if ($retry_checkpoint_override !== null) {
     7846            return [
     7847                'status' => $failed ? 'retry_replay_partial' : 'retry_replay_completed',
     7848                'processed' => $processed,
     7849                'duplicates' => $duplicates,
     7850                'failed' => $failed,
     7851            ];
     7852        }
     7853
     7854        if (isset($batch['next_checkpoint'])) {
     7855            $next_checkpoint['cursor'] = $batch['next_checkpoint'];
     7856        }
     7857        $has_more = !empty($batch['has_more']);
     7858        $failure_counts = statpl_db_get_row(statpl_db_prepare(
     7859            "SELECT SUM(CASE WHEN status = 'retry' THEN 1 ELSE 0 END) AS pending,
     7860                    SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END) AS blocked
     7861             FROM {$this->view_source_failures_table} WHERE source_id = %d AND origin_key = %s",
     7862            $source_id,
     7863            $origin_key
     7864        ), ARRAY_A);
     7865        if (!empty($wpdb->last_error) || !is_array($failure_counts)) {
     7866            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7867            return ['status' => 'failure_ledger_read_failed', 'processed' => $processed, 'duplicates' => $duplicates];
     7868        }
     7869        $pending_failures = (int) $failure_counts['pending'];
     7870        $blocked_failures = (int) $failure_counts['blocked'];
     7871        $origin_status = $has_more ? 'importing' : (($pending_failures > 0 || $blocked_failures > 0) ? ($blocked_failures > 0 ? 'needs_attention' : 'retry_wait') : 'completed');
     7872        if (!$this->generic_advisory_lock_session_is_current($origin_lock)) {
     7873            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7874            return ['status' => 'checkpoint_blocked_origin_lock_session_lost', 'processed' => $processed, 'duplicates' => $duplicates];
     7875        }
     7876
     7877        $updates = [
     7878            'checkpoint_data' => $next_checkpoint,
     7879            'last_error' => ($pending_failures > 0 || $blocked_failures > 0) ? 'One or more historical records remain unresolved; the main import continues independently.' : null,
     7880            'status' => $origin_status,
     7881        ];
     7882        if (!$has_more && $pending_failures === 0 && $blocked_failures === 0) {
     7883            $updates['completed_at'] = current_time('mysql');
     7884        } else {
     7885            $updates['completed_at'] = null;
     7886        }
     7887        $saved = $this->update_view_source_origin_state($source_id, $origin_key, $updates, $origin_lock);
     7888        if (empty($saved['status']) || $saved['status'] !== 'updated') {
     7889            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7890            return ['status' => 'checkpoint_write_failed', 'processed' => $processed, 'duplicates' => $duplicates];
     7891        }
     7892
     7893        // A successful statpl_db_query() confirms that MySQL accepted the UPDATE,
     7894        // but a worker must not report durable progress until the exact state is
     7895        // readable again. This also handles ambiguous acknowledgements and
     7896        // storage-layer anomalies without advancing the scheduler to the next
     7897        // batch on an unverified checkpoint.
     7898        $persisted_state = $this->get_view_source_origin_state($source_id, $origin_key);
     7899        $checkpoint_is_persisted = isset($persisted_state['status'])
     7900            && $persisted_state['status'] === 'found'
     7901            && isset($persisted_state['checkpoint'])
     7902            && wp_json_encode($persisted_state['checkpoint']) === wp_json_encode($next_checkpoint)
     7903            && isset($persisted_state['origin_status'])
     7904            && $persisted_state['origin_status'] === $origin_status;
     7905        $completion_is_persisted = array_key_exists('completed_at', $updates)
     7906            && ((array_key_exists('completed_at', $persisted_state) && $updates['completed_at'] === null && $persisted_state['completed_at'] === null)
     7907                || (array_key_exists('completed_at', $persisted_state) && $updates['completed_at'] !== null && $persisted_state['completed_at'] === $updates['completed_at']));
     7908        if (!$checkpoint_is_persisted || !$completion_is_persisted || !$this->generic_advisory_lock_session_is_current($origin_lock)) {
     7909            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7910            return [
     7911                'status' => 'checkpoint_write_unverified',
     7912                'processed' => $processed,
     7913                'duplicates' => $duplicates,
     7914            ];
     7915        }
     7916
     7917        if ($has_more) {
     7918            $this->schedule_generic_historical_view_import_batch(5, $source_id, $origin_key);
     7919        }
     7920        if ($pending_failures > 0) {
     7921            $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key);
     7922        }
     7923        return [
     7924            'status' => $has_more ? 'batch_completed' : ($pending_failures > 0 ? 'completed_with_pending_retries' : ($blocked_failures > 0 ? 'completed_with_unresolved_records' : 'completed')),
     7925            'processed' => $processed,
     7926            'duplicates' => $duplicates,
     7927            'checkpoint' => $next_checkpoint,
     7928        ];
     7929    }
     7930
     7931    private function register_view_source($source_key, $display_name, $adapter_version, $granularity, $status = 'detected', $adapter_key = null) {
     7932        global $wpdb;
     7933
     7934        $source_key = (string) $source_key;
     7935        $display_name = sanitize_text_field($display_name);
     7936        $adapter_version = sanitize_text_field($adapter_version);
     7937        $granularity = (string) $granularity;
     7938        $status = (string) $status;
     7939        $adapter_key = $adapter_key === null ? null : sanitize_key((string) $adapter_key);
     7940
     7941        if (
     7942            ($adapter_key !== null && ($adapter_key === '' || strlen($adapter_key) > 100))
     7943            ||
     7944            !preg_match('/^[a-z0-9_-]{1,100}$/D', $source_key)
     7945            || $display_name === ''
     7946            || $adapter_version === ''
     7947            || !preg_match('/^[a-z0-9_-]{1,20}$/D', $granularity)
     7948            || !preg_match('/^[a-z0-9_-]{1,20}$/D', $status)
     7949            || strlen($display_name) > 191
     7950            || strlen($adapter_version) > 32
     7951        ) {
     7952            return [
     7953                'status' => 'invalid_input',
     7954                'source_id' => 0,
     7955            ];
     7956        }
     7957
     7958        $now = current_time('mysql');
     7959
     7960        $result = statpl_db_query(statpl_db_prepare(
     7961            "INSERT INTO {$this->view_sources_table}
     7962                (source_key, adapter_key, display_name, adapter_version, granularity, status, created_at, updated_at)
     7963             VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
     7964             ON DUPLICATE KEY UPDATE
     7965                display_name = IF(VALUES(adapter_key) IS NULL OR adapter_key IS NULL OR adapter_key = VALUES(adapter_key), VALUES(display_name), display_name),
     7966                adapter_version = IF(VALUES(adapter_key) IS NULL OR adapter_key IS NULL OR adapter_key = VALUES(adapter_key), VALUES(adapter_version), adapter_version),
     7967                granularity = IF(VALUES(adapter_key) IS NULL OR adapter_key IS NULL OR adapter_key = VALUES(adapter_key), VALUES(granularity), granularity),
     7968                updated_at = IF(VALUES(adapter_key) IS NULL OR adapter_key IS NULL OR adapter_key = VALUES(adapter_key), VALUES(updated_at), updated_at)",
     7969            $source_key,
     7970            $adapter_key,
     7971            $display_name,
     7972            $adapter_version,
     7973            $granularity,
     7974            $status,
     7975            $now,
     7976            $now
     7977        ));
     7978
     7979        if ($result === false || $wpdb->last_error !== '') {
     7980            return [
     7981                'status' => 'database_error',
     7982                'source_id' => 0,
     7983                'error' => $wpdb->last_error,
     7984            ];
     7985        }
     7986
     7987        $source_id = statpl_db_get_var(
     7988            statpl_db_prepare(
     7989                "SELECT id FROM {$this->view_sources_table} WHERE source_key = %s",
     7990                $source_key
     7991            )
     7992        );
     7993
     7994        if (!empty($wpdb->last_error) || !$source_id) {
     7995            return [
     7996                'status' => 'database_error',
     7997                'source_id' => 0,
     7998                'error' => $wpdb->last_error !== ''
     7999                    ? $wpdb->last_error
     8000                    : 'Registered source ID could not be retrieved.',
     8001            ];
     8002        }
     8003
     8004        if ($adapter_key !== null) {
     8005            $ownership_result = statpl_db_query(
     8006                statpl_db_prepare(
     8007                    "UPDATE {$this->view_sources_table}
     8008                     SET adapter_key = %s
     8009                     WHERE id = %d AND (adapter_key IS NULL OR adapter_key = %s)",
     8010                    $adapter_key,
     8011                    (int) $source_id,
     8012                    $adapter_key
     8013                )
     8014            );
     8015            if ($ownership_result === false || $wpdb->last_error !== '') {
     8016                return [
     8017                    'status' => 'database_error',
     8018                    'source_id' => 0,
     8019                    'error' => $wpdb->last_error,
     8020                ];
     8021            }
     8022
     8023            $stored_adapter_key = statpl_db_get_var(statpl_db_prepare(
     8024                "SELECT adapter_key FROM {$this->view_sources_table} WHERE id = %d",
     8025                (int) $source_id
     8026            ));
     8027            if ($wpdb->last_error !== '') {
     8028                return [
     8029                    'status' => 'database_error',
     8030                    'source_id' => 0,
     8031                    'error' => $wpdb->last_error,
     8032                ];
     8033            }
     8034            if ((string) $stored_adapter_key !== $adapter_key) {
     8035                return [
     8036                    'status' => 'adapter_mismatch',
     8037                    'source_id' => 0,
     8038                ];
     8039            }
     8040        }
     8041
     8042        return [
     8043            'status' => 'registered',
     8044            'source_id' => (int) $source_id,
     8045        ];
     8046    }
     8047
     8048    private function register_view_source_origin($source_id, $origin_key, $adapter_key, $capabilities = [], $detection_status = 'unknown') {
     8049        global $wpdb;
     8050
     8051        $source_id = absint($source_id);
     8052        $origin_key = (string) $origin_key;
     8053        $adapter_key = (string) $adapter_key;
     8054        $detection_status = (string) $detection_status;
     8055
     8056        if (
     8057            !$source_id
     8058            || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key)
     8059            || !preg_match('/^[a-z0-9_-]{1,100}$/D', $adapter_key)
     8060            || !in_array($detection_status, ['unknown', 'available', 'missing_table', 'incompatible_schema', 'database_error'], true)
     8061            || !is_array($capabilities)
     8062        ) {
     8063            return [
     8064                'status' => 'invalid_input',
     8065                'source_id' => $source_id,
     8066                'origin_key' => $origin_key,
     8067            ];
     8068        }
     8069
     8070        $source_exists = statpl_db_get_var(
     8071            statpl_db_prepare(
     8072                "SELECT id FROM {$this->view_sources_table} WHERE id = %d",
     8073                $source_id
     8074            )
     8075        );
     8076
     8077        if (!empty($wpdb->last_error)) {
     8078            return [
     8079                'status' => 'database_error',
     8080                'source_id' => $source_id,
     8081                'origin_key' => $origin_key,
     8082                'error' => $wpdb->last_error,
     8083            ];
     8084        }
     8085
     8086        if (!$source_exists) {
     8087            return [
     8088                'status' => 'source_not_found',
     8089                'source_id' => $source_id,
     8090                'origin_key' => $origin_key,
     8091            ];
     8092        }
     8093
     8094        // An adapter identity is part of the immutable meaning of an origin's
     8095        // snapshot and checkpoint. Never silently replace it after initialization.
     8096        $existing_origin = statpl_db_get_row(
     8097            statpl_db_prepare(
     8098                "SELECT adapter_key, snapshot_data, checkpoint_data, status
     8099                 FROM {$this->view_source_origins_table}
     8100                 WHERE source_id = %d AND origin_key = %s",
     8101                $source_id,
     8102                $origin_key
     8103            ),
     8104            ARRAY_A
     8105        );
     8106
     8107        if (!empty($wpdb->last_error)) {
     8108            return [
     8109                'status' => 'database_error',
     8110                'source_id' => $source_id,
     8111                'origin_key' => $origin_key,
     8112                'error' => $wpdb->last_error,
     8113            ];
     8114        }
     8115
     8116        $existing_source_events = 0;
     8117        $existing_origin_failures = 0;
     8118        if (is_array($existing_origin) && isset($existing_origin['adapter_key']) && (string) $existing_origin['adapter_key'] !== $adapter_key) {
     8119            $existing_source_events = (int) statpl_db_get_var(statpl_db_prepare(
     8120                "SELECT COUNT(*) FROM {$this->view_source_events_table} WHERE source_id = %d",
     8121                $source_id
     8122            ));
     8123            if (!empty($wpdb->last_error)) {
     8124                return [
     8125                    'status' => 'database_error',
     8126                    'source_id' => $source_id,
     8127                    'origin_key' => $origin_key,
     8128                    'error' => $wpdb->last_error,
     8129                ];
     8130            }
     8131            $existing_origin_failures = (int) statpl_db_get_var(statpl_db_prepare(
     8132                "SELECT COUNT(*) FROM {$this->view_source_failures_table} WHERE source_id = %d AND origin_key = %s",
     8133                $source_id,
     8134                $origin_key
     8135            ));
     8136            if (!empty($wpdb->last_error)) {
     8137                return [
     8138                    'status' => 'database_error',
     8139                    'source_id' => $source_id,
     8140                    'origin_key' => $origin_key,
     8141                    'error' => $wpdb->last_error,
     8142                ];
     8143            }
     8144        }
     8145
     8146        if (
     8147            is_array($existing_origin)
     8148            && isset($existing_origin['adapter_key'])
     8149            && (string) $existing_origin['adapter_key'] !== $adapter_key
     8150            && (
     8151                !empty($existing_origin['snapshot_data'])
     8152                || !empty($existing_origin['checkpoint_data'])
     8153                || !in_array($existing_origin['status'], ['pending', 'unavailable'], true)
     8154                || $existing_source_events > 0
     8155                || $existing_origin_failures > 0
     8156            )
     8157        ) {
     8158            $message = sprintf(
     8159                'Historical adapter identity changed from "%1$s" to "%2$s" after this origin was initialized. Existing historical data and checkpoints were preserved; the replacement adapter was not allowed to reinterpret them.',
     8160                sanitize_key((string) $existing_origin['adapter_key']),
     8161                sanitize_key($adapter_key)
     8162            );
     8163            // A replacement adapter cannot safely inherit the old adapter's
     8164            // snapshot or checkpoint. Keep the origin automatically unavailable
     8165            // (rather than requiring administrator intervention), while normal
     8166            // discovery continues to look for the original adapter. If that
     8167            // original identity returns, the ordinary registration path can resume
     8168            // from the preserved checkpoint without reinterpreting historical data.
     8169            statpl_db_update(
     8170                $this->view_source_origins_table,
     8171                [
     8172                    'status' => 'unavailable',
     8173                    'last_error' => $message,
     8174                    'updated_at' => current_time('mysql'),
     8175                ],
     8176                [
     8177                    'source_id' => $source_id,
     8178                    'origin_key' => $origin_key,
     8179                ],
     8180                ['%s', '%s', '%s'],
     8181                ['%d', '%s']
     8182            );
     8183            if (!empty($wpdb->last_error)) {
     8184                return [
     8185                    'status' => 'database_error',
     8186                    'source_id' => $source_id,
     8187                    'origin_key' => $origin_key,
     8188                    'error' => $wpdb->last_error,
     8189                ];
     8190            }
     8191            return [
     8192                'status' => 'adapter_mismatch',
     8193                'source_id' => $source_id,
     8194                'origin_key' => $origin_key,
     8195            ];
     8196        }
     8197
     8198        $capabilities_json = wp_json_encode($capabilities);
     8199
     8200        if (!is_string($capabilities_json)) {
     8201            return [
     8202                'status' => 'invalid_capabilities',
     8203                'source_id' => $source_id,
     8204                'origin_key' => $origin_key,
     8205            ];
     8206        }
     8207
     8208        $now = current_time('mysql');
     8209
     8210        $result = statpl_db_query(
     8211            statpl_db_prepare(
     8212                "INSERT INTO {$this->view_source_origins_table}
     8213                    (source_id, origin_key, adapter_key, capabilities, detection_status, status, updated_at)
     8214                 VALUES (%d, %s, %s, %s, %s, %s, %s)
     8215                 ON DUPLICATE KEY UPDATE
     8216                    adapter_key = VALUES(adapter_key),
     8217                    capabilities = VALUES(capabilities),
     8218                    detection_status = %s,
     8219                    status = CASE
     8220                        WHEN VALUES(detection_status) <> 'available' AND status NOT IN ('completed', 'needs_attention') THEN 'unavailable'
     8221                        WHEN VALUES(detection_status) = 'available' AND status = 'unavailable' THEN 'pending'
     8222                        ELSE status
     8223                    END,
     8224                    updated_at = %s",
     8225                $source_id,
     8226                $origin_key,
     8227                $adapter_key,
     8228                $capabilities_json,
     8229                $detection_status,
     8230                $detection_status === 'available' ? 'pending' : 'unavailable',
     8231                $now,
     8232                $detection_status,
     8233                $now
     8234            )
     8235        );
     8236
     8237        if ($result === false || $wpdb->last_error !== '') {
     8238            return [
     8239                'status' => 'database_error',
     8240                'source_id' => $source_id,
     8241                'origin_key' => $origin_key,
     8242                'error' => $wpdb->last_error,
     8243            ];
     8244        }
     8245
     8246        return [
     8247            'status' => 'registered',
     8248            'source_id' => $source_id,
     8249            'origin_key' => $origin_key,
     8250        ];
     8251    }
     8252
     8253    private function get_view_source_origin_state($source_id, $origin_key) {
     8254        global $wpdb;
     8255
     8256        $source_id = absint($source_id);
     8257        $origin_key = is_string($origin_key) ? trim($origin_key) : '';
     8258
     8259        if (
     8260            !$source_id
     8261            || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key)
     8262        ) {
     8263            return [
     8264                'status' => 'invalid_input',
     8265            ];
     8266        }
     8267
     8268        $row = statpl_db_get_row(
     8269            statpl_db_prepare(
     8270                "SELECT
     8271                    source_id,
     8272                    origin_key,
     8273                    adapter_key,
     8274                    capabilities,
     8275                    snapshot_data,
     8276                    checkpoint_data,
     8277                    detection_status,
     8278                    status,
     8279                    last_error,
     8280                    started_at,
     8281                    completed_at,
     8282                    updated_at
     8283                 FROM {$this->view_source_origins_table}
     8284                 WHERE source_id = %d
     8285                   AND origin_key = %s",
     8286                $source_id,
     8287                $origin_key
     8288            ),
     8289            ARRAY_A
     8290        );
     8291
     8292        if (!empty($wpdb->last_error)) {
     8293            return [
     8294                'status' => 'database_error',
     8295                'error' => $wpdb->last_error,
     8296            ];
     8297        }
     8298
     8299        if (!$row) {
     8300            return [
     8301                'status' => 'not_found',
     8302                'source_id' => $source_id,
     8303                'origin_key' => $origin_key,
     8304            ];
     8305        }
     8306
     8307        $capabilities = null;
     8308        if ($row['capabilities'] !== null && $row['capabilities'] !== '') {
     8309            $capabilities = json_decode($row['capabilities'], true);
     8310            if (json_last_error() !== JSON_ERROR_NONE) {
     8311                return [
     8312                    'status' => 'invalid_stored_data',
     8313                    'source_id' => $source_id,
     8314                    'origin_key' => $origin_key,
     8315                    'field' => 'capabilities',
     8316                ];
     8317            }
     8318        }
     8319
     8320        $snapshot = null;
     8321        if ($row['snapshot_data'] !== null && $row['snapshot_data'] !== '') {
     8322            $snapshot = json_decode($row['snapshot_data'], true);
     8323            if (json_last_error() !== JSON_ERROR_NONE) {
     8324                return [
     8325                    'status' => 'invalid_stored_data',
     8326                    'source_id' => $source_id,
     8327                    'origin_key' => $origin_key,
     8328                    'field' => 'snapshot_data',
     8329                ];
     8330            }
     8331        }
     8332
     8333        $checkpoint = null;
     8334        if ($row['checkpoint_data'] !== null && $row['checkpoint_data'] !== '') {
     8335            $checkpoint = json_decode($row['checkpoint_data'], true);
     8336            if (json_last_error() !== JSON_ERROR_NONE) {
     8337                return [
     8338                    'status' => 'invalid_stored_data',
     8339                    'source_id' => $source_id,
     8340                    'origin_key' => $origin_key,
     8341                    'field' => 'checkpoint_data',
     8342                ];
     8343            }
     8344        }
     8345
     8346        return [
     8347            'status' => 'found',
     8348            'source_id' => (int) $row['source_id'],
     8349            'origin_key' => $row['origin_key'],
     8350            'adapter_key' => $row['adapter_key'],
     8351            'capabilities' => $capabilities,
     8352            'snapshot' => $snapshot,
     8353            'checkpoint' => $checkpoint,
     8354            'detection_status' => $row['detection_status'],
     8355            'origin_status' => $row['status'],
     8356            'last_error' => $row['last_error'],
     8357            'started_at' => $row['started_at'],
     8358            'completed_at' => $row['completed_at'],
     8359            'updated_at' => $row['updated_at'],
     8360        ];
     8361    }
     8362
     8363    private function update_view_source_origin_state($source_id, $origin_key, $updates, $lock_context = null) {
     8364        global $wpdb;
     8365
     8366        $source_id = absint($source_id);
     8367        $origin_key = is_string($origin_key) ? trim($origin_key) : '';
     8368
     8369        if (
     8370            !$source_id
     8371            || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key)
     8372            || !is_array($updates)
     8373            || empty($updates)
     8374        ) {
     8375            return [
     8376                'status' => 'invalid_input',
     8377            ];
     8378        }
     8379
     8380        $allowed_fields = [
     8381            'snapshot_data',
     8382            'checkpoint_data',
     8383            'status',
     8384            'last_error',
     8385            'started_at',
     8386            'completed_at',
     8387        ];
     8388
     8389        $columns = [];
     8390        $values = [];
     8391
     8392        foreach ($updates as $field => $value) {
     8393            if (!in_array($field, $allowed_fields, true)) {
     8394                return [
     8395                    'status' => 'invalid_field',
     8396                    'field' => $field,
     8397                ];
     8398            }
     8399
     8400            if (in_array($field, ['snapshot_data', 'checkpoint_data'], true)) {
     8401                if ($value !== null && !is_array($value)) {
     8402                    return [
     8403                        'status' => 'invalid_input',
     8404                        'field' => $field,
     8405                    ];
     8406                }
     8407
     8408                $encoded = $value === null ? null : wp_json_encode($value);
     8409
     8410                if ($value !== null && !is_string($encoded)) {
     8411                    return [
     8412                        'status' => 'encoding_error',
     8413                        'field' => $field,
     8414                    ];
     8415                }
     8416
     8417                $columns[] = "`{$field}` = %s";
     8418                $values[] = $encoded;
     8419                continue;
     8420            }
     8421
     8422            if ($field === 'status') {
     8423                if (
     8424                    !is_string($value)
     8425                    || !preg_match('/^[a-z0-9_-]{1,20}$/D', $value)
     8426                ) {
     8427                    return [
     8428                        'status' => 'invalid_input',
     8429                        'field' => $field,
     8430                    ];
     8431                }
     8432
     8433                $columns[] = "`{$field}` = %s";
     8434                $values[] = $value;
     8435                continue;
     8436            }
     8437
     8438            if ($field === 'last_error') {
     8439                if ($value !== null && !is_string($value)) {
     8440                    return [
     8441                        'status' => 'invalid_input',
     8442                        'field' => $field,
     8443                    ];
     8444                }
     8445
     8446                $columns[] = "`{$field}` = %s";
     8447                $values[] = $value;
     8448                continue;
     8449            }
     8450
     8451            if ($value !== null && (
     8452                !is_string($value)
     8453                || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $value)
     8454            )) {
     8455                return [
     8456                    'status' => 'invalid_input',
     8457                    'field' => $field,
     8458                ];
     8459            }
     8460
     8461            $columns[] = "`{$field}` = %s";
     8462            $values[] = $value;
     8463        }
     8464
     8465        $columns[] = '`updated_at` = %s';
     8466        $values[] = current_time('mysql');
     8467
     8468        $values[] = $source_id;
     8469        $values[] = $origin_key;
     8470
     8471        $lock_name = '';
     8472        if ($lock_context !== null) {
     8473            if (!$this->generic_advisory_lock_session_is_current($lock_context) || empty($lock_context['lock_name'])) {
     8474                return ['status' => 'lock_session_lost'];
     8475            }
     8476            $lock_name = (string) $lock_context['lock_name'];
     8477        }
     8478        // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The optional lock predicate and its matching lock-name argument are both added only when lock_context is present.
     8479        $lock_predicate = $lock_context !== null ? ' AND IS_USED_LOCK(%s) = CONNECTION_ID()' : '';
     8480        if ($lock_context !== null) {
     8481            $values[] = $lock_name;
     8482        }
     8483        // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- SET placeholders and values are appended together from the validated origin-state field allowlist; source_id/origin_key and the optional lock placeholder are appended to the values in matching order.
     8484// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     8485        $result = statpl_db_query(/* phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Placeholder fragments and their values are assembled together from the same fixed allowlists; optional lock predicates and matching values are added together. */ statpl_db_prepare(
     8486            "UPDATE {$this->view_source_origins_table}
     8487             SET " . implode(', ', /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $columns) . "
     8488             WHERE source_id = %d AND origin_key = %s" . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $lock_predicate,
     8489            ...$values
     8490        ));
     8491
     8492        if ($result === false || $wpdb->last_error !== '') {
     8493            return [
     8494                'status' => 'database_error',
     8495                'error' => $wpdb->last_error,
     8496            ];
     8497        }
     8498
     8499        if ($result === 0) {
     8500            if ($lock_context !== null && !$this->generic_advisory_lock_session_is_current($lock_context)) {
     8501                return ['status' => 'lock_session_lost'];
     8502            }
     8503            $exists = statpl_db_get_var(
     8504                statpl_db_prepare(
     8505                    "SELECT 1 FROM {$this->view_source_origins_table}
     8506                     WHERE source_id = %d AND origin_key = %s",
     8507                    $source_id,
     8508                    $origin_key
     8509                )
     8510            );
     8511
     8512            if (!empty($wpdb->last_error)) {
     8513                return [
     8514                    'status' => 'database_error',
     8515                    'error' => $wpdb->last_error,
     8516                ];
     8517            }
     8518
     8519            // The existence read can itself trigger a transparent wpdb reconnect.
     8520            // Revalidate the original lock session after that read before treating
     8521            // a zero-row UPDATE as a successful no-op. Otherwise a replacement
     8522            // connection could make an unperformed state transition look complete.
     8523            if ($lock_context !== null && !$this->generic_advisory_lock_session_is_current($lock_context)) {
     8524                return ['status' => 'lock_session_lost'];
     8525            }
     8526
     8527            if (!$exists) {
     8528                return [
     8529                    'status' => 'not_found',
     8530                    'source_id' => $source_id,
     8531                    'origin_key' => $origin_key,
     8532                ];
     8533            }
     8534        }
     8535
     8536        return [
     8537            'status' => 'updated',
     8538            'source_id' => $source_id,
     8539            'origin_key' => $origin_key,
     8540        ];
     8541    }
     8542
     8543    private function save_view_source_origin_snapshot_if_empty($source_id, $origin_key, $snapshot) {
     8544        global $wpdb;
     8545
     8546        $source_id = absint($source_id);
     8547        $origin_key = is_string($origin_key) ? trim($origin_key) : '';
     8548
     8549        if (
     8550            !$source_id
     8551            || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key)
     8552            || !is_array($snapshot)
     8553            || empty($snapshot)
     8554        ) {
     8555            return [
     8556                'status' => 'invalid_input',
     8557            ];
     8558        }
     8559
     8560        $encoded = wp_json_encode($snapshot);
     8561
     8562        if (!is_string($encoded)) {
     8563            return [
     8564                'status' => 'encoding_error',
     8565            ];
     8566        }
     8567
     8568        $result = statpl_db_query(
     8569            statpl_db_prepare(
     8570                "UPDATE {$this->view_source_origins_table}
     8571                 SET snapshot_data = %s, updated_at = %s
     8572                 WHERE source_id = %d
     8573                 AND origin_key = %s
     8574                 AND snapshot_data IS NULL",
     8575                $encoded,
     8576                current_time('mysql'),
     8577                $source_id,
     8578                $origin_key
     8579            )
     8580        );
     8581
     8582        if ($result === false || $wpdb->last_error !== '') {
     8583            return [
     8584                'status' => 'database_error',
     8585                'error' => $wpdb->last_error,
     8586            ];
     8587        }
     8588
     8589        if ($result === 1) {
     8590            return [
     8591                'status' => 'updated',
     8592                'source_id' => $source_id,
     8593                'origin_key' => $origin_key,
     8594            ];
     8595        }
     8596
     8597        $existing = $this->get_view_source_origin_state($source_id, $origin_key);
     8598
     8599        if ($existing['status'] !== 'found') {
     8600            return $existing;
     8601        }
     8602
     8603        if ($existing['snapshot'] !== null) {
     8604            return [
     8605                'status' => 'already_initialized',
     8606                'source_id' => $source_id,
     8607                'origin_key' => $origin_key,
     8608                'snapshot' => $existing['snapshot'],
     8609            ];
     8610        }
     8611
     8612        return [
     8613            'status' => 'database_error',
     8614            'source_id' => $source_id,
     8615            'origin_key' => $origin_key,
     8616        ];
     8617    }
     8618
     8619    private function initialize_view_source_origin_snapshot($source_id, $origin_key) {
     8620        global $wpdb;
     8621
     8622        $state = $this->get_view_source_origin_state($source_id, $origin_key);
     8623
     8624        if ($state['status'] !== 'found') {
     8625            return $state;
     8626        }
     8627
     8628        if (!empty($state['snapshot'])) {
     8629            return [
     8630                'status' => 'already_initialized',
     8631                'source_id' => (int) $source_id,
     8632                'origin_key' => $origin_key,
     8633                'snapshot' => $state['snapshot'],
     8634            ];
     8635        }
     8636
     8637        if ($state['detection_status'] !== 'available') {
     8638            return [
     8639                'status' => 'origin_unavailable',
     8640                'source_id' => (int) $source_id,
     8641                'origin_key' => $origin_key,
     8642                'detection_status' => $state['detection_status'],
     8643            ];
     8644        }
     8645
     8646        $origin_tables = [
     8647            'slim_stats' => $wpdb->prefix . 'slim_stats',
     8648            'slim_stats_archive' => $wpdb->prefix . 'slim_stats_archive',
     8649        ];
     8650
     8651        if (!isset($origin_tables[$origin_key])) {
     8652            return [
     8653                'status' => 'unsupported_origin',
     8654                'source_id' => (int) $source_id,
     8655                'origin_key' => $origin_key,
     8656            ];
     8657        }
     8658
     8659        $table = $origin_tables[$origin_key];
     8660
     8661        $max_id = statpl_db_get_var(
     8662            "SELECT MAX(id) FROM `{$table}`"
     8663        );
     8664
     8665        if (!empty($wpdb->last_error)) {
     8666            return [
     8667                'status' => 'database_error',
     8668                'source_id' => (int) $source_id,
     8669                'origin_key' => $origin_key,
     8670                'error' => $wpdb->last_error,
     8671            ];
     8672        }
     8673
     8674        $snapshot = [
     8675            'max_id' => $max_id === null ? 0 : (int) $max_id,
     8676            'captured_at' => current_time('mysql'),
     8677        ];
     8678
     8679        $saved = $this->save_view_source_origin_snapshot_if_empty(
     8680            $source_id,
     8681            $origin_key,
     8682            $snapshot
     8683        );
     8684
     8685        if ($saved['status'] === 'already_initialized') {
     8686            return $saved;
     8687        }
     8688
     8689        if ($saved['status'] !== 'updated') {
     8690            return $saved;
     8691        }
     8692
     8693        return [
     8694            'status' => 'initialized',
     8695            'source_id' => (int) $source_id,
     8696            'origin_key' => $origin_key,
     8697            'snapshot' => $snapshot,
     8698        ];
     8699    }
     8700
     8701    private function save_view_source_origin_snapshot($source_id, $origin_key, $snapshot) {
     8702        if (!is_array($snapshot) || empty($snapshot)) {
     8703            return [
     8704                'status' => 'invalid_snapshot',
     8705            ];
     8706        }
     8707
     8708        return $this->update_view_source_origin_state(
     8709            $source_id,
     8710            $origin_key,
     8711            [
     8712                'snapshot_data' => $snapshot,
     8713            ]
     8714        );
     8715    }
     8716
     8717    private function save_view_source_origin_checkpoint($source_id, $origin_key, $checkpoint) {
     8718        if (!is_array($checkpoint) || empty($checkpoint)) {
     8719            return [
     8720                'status' => 'invalid_checkpoint',
     8721            ];
     8722        }
     8723
     8724        return $this->update_view_source_origin_state(
     8725            $source_id,
     8726            $origin_key,
     8727            [
     8728                'checkpoint_data' => $checkpoint,
     8729            ]
     8730        );
     8731    }
     8732
     8733    private function detect_slimstat_view_origins() {
     8734        global $wpdb;
     8735
     8736        $origins = [
     8737            'slim_stats' => $wpdb->prefix . 'slim_stats',
     8738            'slim_stats_archive' => $wpdb->prefix . 'slim_stats_archive',
     8739        ];
     8740
     8741        $required_columns = [
     8742            'id',
     8743            'content_type',
     8744            'content_id',
     8745            'dt',
     8746        ];
     8747
     8748        $results = [];
     8749
     8750        foreach ($origins as $origin_key => $table) {
     8751            $exists = statpl_db_get_var(
     8752                statpl_db_prepare(
     8753                    'SHOW TABLES LIKE %s',
     8754                    $wpdb->esc_like($table)
     8755                )
     8756            );
     8757
     8758            if (!empty($wpdb->last_error)) {
     8759                $results[$origin_key] = [
     8760                    'status' => 'database_error',
     8761                    'error' => $wpdb->last_error,
     8762                ];
     8763                continue;
     8764            }
     8765
     8766            if ($exists !== $table) {
     8767                $results[$origin_key] = [
     8768                    'status' => 'missing_table',
     8769                ];
     8770                continue;
     8771            }
     8772
     8773            $columns = statpl_db_get_col(
     8774                "SHOW COLUMNS FROM `{$table}`",
     8775                0
     8776            );
     8777
     8778            if (!empty($wpdb->last_error)) {
     8779                $results[$origin_key] = [
     8780                    'status' => 'database_error',
     8781                    'error' => $wpdb->last_error,
     8782                ];
     8783                continue;
     8784            }
     8785
     8786            $missing_columns = array_values(
     8787                array_diff($required_columns, $columns)
     8788            );
     8789
     8790            if (!empty($missing_columns)) {
     8791                $results[$origin_key] = [
     8792                    'status' => 'incompatible_schema',
     8793                    'missing_columns' => $missing_columns,
     8794                ];
     8795                continue;
     8796            }
     8797
     8798            $results[$origin_key] = [
     8799                'status' => 'available',
     8800                'capabilities' => [
     8801                    'historical_views' => true,
     8802                    'record_id' => true,
     8803                    'product_reference' => true,
     8804                    'event_timestamp' => true,
     8805                ],
     8806            ];
     8807        }
     8808
     8809        return $results;
     8810    }
     8811
     8812    private function initialize_slimstat_view_source_origins() {
     8813        $registration = $this->register_slimstat_view_source();
     8814
     8815        if (
     8816            !in_array($registration['status'], ['registered', 'partial'], true)
     8817            || empty($registration['source']['source_id'])
     8818        ) {
     8819            return [
     8820                'status' => 'registration_failed',
     8821                'registration' => $registration,
     8822                'origins' => [],
     8823            ];
     8824        }
     8825
     8826        $source_id = (int) $registration['source']['source_id'];
     8827        $origins = [];
     8828        $initialization_failed = false;
     8829        $available_origins = 0;
     8830
     8831        foreach ($registration['origins'] as $origin_key => $origin_data) {
     8832            $origin_registration = isset($origin_data['registration'])
     8833                ? $origin_data['registration']
     8834                : [];
     8835
     8836            if (($origin_registration['status'] ?? '') !== 'registered') {
     8837                $origins[$origin_key] = [
     8838                    'status' => 'registration_failed',
     8839                    'registration' => $origin_registration,
     8840                ];
     8841                $initialization_failed = true;
     8842                continue;
     8843            }
     8844
     8845            $detection_status = $origin_data['detection']['status'] ?? 'unknown';
     8846
     8847            if ($detection_status !== 'available') {
     8848                $origins[$origin_key] = [
     8849                    'status' => 'skipped',
     8850                    'reason' => $detection_status,
     8851                ];
     8852                continue;
     8853            }
     8854
     8855            $available_origins++;
     8856
     8857            $result = $this->initialize_view_source_origin_snapshot(
     8858                $source_id,
     8859                $origin_key
     8860            );
     8861
     8862            $origins[$origin_key] = $result;
     8863
     8864            if (!in_array(
     8865                $result['status'],
     8866                ['initialized', 'already_initialized'],
     8867                true
     8868            )) {
     8869                $initialization_failed = true;
     8870            }
     8871        }
     8872
     8873        if ($available_origins === 0 && !$initialization_failed) {
     8874            $status = 'no_available_origins';
     8875        } else {
     8876            $status = $initialization_failed ? 'partial' : 'initialized';
     8877        }
     8878
     8879        return [
     8880            'status' => $status,
     8881            'source_id' => $source_id,
     8882            'registration' => $registration,
     8883            'origins' => $origins,
     8884        ];
     8885    }
     8886
     8887    private function register_slimstat_view_source() {
     8888        $source = $this->register_view_source(
     8889            'slimstat',
     8890            'SlimStat',
     8891            '1.0.0',
     8892            'event',
     8893            'registered'
     8894        );
     8895
     8896        if ($source['status'] !== 'registered' || empty($source['source_id'])) {
     8897            return [
     8898                'status' => 'source_registration_failed',
     8899                'source' => $source,
     8900                'origins' => [],
     8901            ];
     8902        }
     8903
     8904        $source_id = (int) $source['source_id'];
     8905        $detected_origins = $this->detect_slimstat_view_origins();
     8906        $origins = [];
     8907        $registration_failed = false;
     8908
     8909        foreach ($detected_origins as $origin_key => $detection) {
     8910            $capabilities = isset($detection['capabilities'])
     8911                ? $detection['capabilities']
     8912                : [];
     8913
     8914            $registration = $this->register_view_source_origin(
     8915                $source_id,
     8916                $origin_key,
     8917                'slimstat',
     8918                $capabilities,
     8919                in_array($detection['status'], ['unknown', 'available', 'missing_table', 'incompatible_schema', 'database_error'], true)
     8920                    ? $detection['status']
     8921                    : 'unknown'
     8922            );
     8923
     8924            $origins[$origin_key] = [
     8925                'detection' => $detection,
     8926                'registration' => $registration,
     8927            ];
     8928
     8929            if ($registration['status'] !== 'registered') {
     8930                $registration_failed = true;
     8931            }
     8932        }
     8933
     8934        return [
     8935            'status' => $registration_failed ? 'partial' : 'registered',
     8936            'source' => $source,
     8937            'origins' => $origins,
     8938        ];
     8939    }
     8940
     8941    private function detect_slimstat_source() {
     8942    global $wpdb;
     8943
     8944    // Check whether SlimStat is installed and loaded.
     8945    if (!class_exists('wp_slimstat', false)) {
     8946        return [
     8947            'status' => 'not_installed',
     8948        ];
     8949    }
     8950
     8951    // SlimStat source tables.
     8952    $tables = [
     8953        'active'  => $wpdb->prefix . 'slim_stats',
     8954        'archive' => $wpdb->prefix . 'slim_stats_archive',
     8955    ];
     8956
     8957    $missing_tables = [];
     8958
     8959    // Check that both tables exist.
     8960    foreach ($tables as $key => $table) {
     8961        $exists = statpl_db_get_var(
     8962            statpl_db_prepare(
     8963                'SHOW TABLES LIKE %s',
     8964                $wpdb->esc_like($table)
     8965            )
     8966        );
     8967
     8968        if (!empty($wpdb->last_error)) {
     8969            return [
     8970                'status' => 'database_error',
     8971            ];
     8972        }
     8973
     8974        if ($exists !== $table) {
     8975            $missing_tables[] = $table;
     8976        }
     8977    }
     8978
     8979    if (!empty($missing_tables)) {
     8980        return [
     8981            'status'  => 'missing_tables',
     8982            'missing' => $missing_tables,
     8983        ];
     8984    }
     8985
     8986    // Required columns for historical product-view import.
     8987    $required_columns = [
     8988        'id',
     8989        'content_type',
     8990        'content_id',
     8991        'dt',
     8992    ];
     8993
     8994    $incompatible_tables = [];
     8995
     8996    // Check the required schema in both tables.
     8997    foreach ($tables as $key => $table) {
     8998        $columns = statpl_db_get_col(
     8999            "SHOW COLUMNS FROM `{$table}`",
     9000            0
     9001        );
     9002
     9003        if (!empty($wpdb->last_error)) {
     9004            return [
     9005                'status' => 'database_error',
     9006            ];
     9007        }
     9008
     9009        $missing_columns = array_diff(
     9010            $required_columns,
     9011            $columns
     9012        );
     9013
     9014        if (!empty($missing_columns)) {
     9015            $incompatible_tables[$table] = array_values(
     9016                $missing_columns
     9017            );
     9018        }
     9019    }
     9020
     9021    if (!empty($incompatible_tables)) {
     9022        return [
     9023            'status'          => 'incompatible_schema',
     9024            'missing_columns' => $incompatible_tables,
     9025        ];
     9026    }
     9027
     9028    return [
     9029        'status' => 'available',
     9030        'tables' => $tables,
     9031    ];
     9032}
     9033    private function get_slimstat_view_snapshot() {
     9034    global $wpdb;
     9035
     9036    $source = $this->detect_slimstat_source();
     9037
     9038    if ($source['status'] !== 'available') {
     9039        return $source;
     9040    }
     9041
     9042    $snapshot = [];
     9043
     9044    foreach ($source['tables'] as $key => $table) {
     9045        $max_id = statpl_db_get_var(
     9046    statpl_db_prepare(
     9047        "SELECT MAX(id) FROM `{$table}` WHERE content_type = %s",
     9048        'cpt:product'
     9049      )
     9050   );
     9051
     9052        if (!empty($wpdb->last_error)) {
     9053            return [
     9054                'status' => 'database_error',
     9055            ];
     9056        }
     9057
     9058        $snapshot[$key] = (int) $max_id;
     9059    }
     9060
     9061    return [
     9062        'status'   => 'available',
     9063        'snapshot' => $snapshot,
     9064    ];
     9065}
     9066private function initialize_slimstat_view_import() {
     9067    global $wpdb;
     9068
     9069    $existing = statpl_db_get_var(
     9070        statpl_db_prepare(
     9071            "SELECT source FROM {$this->view_import_state_table} WHERE source = %s",
     9072            'slimstat'
     9073        )
     9074    );
     9075
     9076    if (!empty($wpdb->last_error)) {
     9077        return [
     9078            'status' => 'database_error',
     9079        ];
     9080    }
     9081
     9082    if ($existing === 'slimstat') {
     9083        return [
     9084            'status' => 'already_initialized',
     9085        ];
     9086    }
     9087
     9088    $result = $this->get_slimstat_view_snapshot();
     9089
     9090    if ($result['status'] !== 'available') {
     9091        return $result;
     9092    }
     9093
     9094    $inserted = statpl_db_query(
     9095        statpl_db_prepare(
     9096            "INSERT IGNORE INTO {$this->view_import_state_table}
     9097            (source, snapshot_max_id, snapshot_active_max_id, snapshot_archive_max_id, last_processed_id, status, updated_at)
     9098        VALUES (%s, %d, %d, %d, %d, %s, %s)",
     9099                       'slimstat',
     9100                       max($result['snapshot']['active'], $result['snapshot']['archive']),
     9101                       $result['snapshot']['active'],
     9102                       $result['snapshot']['archive'],
     9103                       0,
     9104                       'pending',
     9105                       current_time('mysql')
     9106        )
     9107    );
     9108
     9109    if (false === $inserted) {
     9110        return [
     9111            'status' => 'database_error',
     9112        ];
     9113    }
     9114
     9115    if (0 === $inserted) {
     9116        $existing = statpl_db_get_var(
     9117            statpl_db_prepare(
     9118                "SELECT source FROM {$this->view_import_state_table} WHERE source = %s",
     9119                'slimstat'
     9120            )
     9121        );
     9122
     9123        if (!empty($wpdb->last_error)) {
     9124            return [
     9125                'status' => 'database_error',
     9126            ];
     9127        }
     9128
     9129        if ($existing === 'slimstat') {
     9130            return [
     9131                'status' => 'already_initialized',
     9132            ];
     9133        }
     9134
     9135        return [
     9136            'status' => 'insert_not_confirmed',
     9137        ];
     9138    }
     9139
     9140    return [
     9141        'status' => 'initialized',
     9142        'snapshot' => $result['snapshot'],
     9143    ];
     9144}
     9145
     9146    /**
     9147     * Retrieve the next historical SlimStat product-view batch.
     9148     * This method is read-only: it never changes the checkpoint or import tables.
     9149     */
     9150    private function get_slimstat_view_import_batch($last_processed_id, $batch_size = 50) {
     9151        global $wpdb;
     9152
     9153        $state = statpl_db_get_row(
     9154            statpl_db_prepare(
     9155                "SELECT snapshot_active_max_id, snapshot_archive_max_id
     9156                 FROM {$this->view_import_state_table}
     9157                 WHERE source = %s",
     9158                'slimstat'
     9159            ),
     9160            ARRAY_A
     9161        );
     9162
     9163        if (!empty($wpdb->last_error)) {
     9164            return [
     9165                'status' => 'database_error',
     9166                'rows'   => [],
     9167            ];
     9168        }
     9169
     9170        if (!is_array($state)) {
     9171            return [
     9172                'status' => 'state_missing',
     9173                'rows'   => [],
     9174            ];
     9175        }
     9176
     9177        $active_max_id  = (int) $state['snapshot_active_max_id'];
     9178        $archive_max_id = (int) $state['snapshot_archive_max_id'];
     9179        $last_processed_id = max(0, (int) $last_processed_id);
     9180        $batch_size = max(1, min(500, (int) $batch_size));
     9181
     9182        $active_table  = $wpdb->prefix . 'slim_stats';
     9183        $archive_table = $wpdb->prefix . 'slim_stats_archive';
     9184
     9185        $sql = "
     9186            SELECT id, product_id, dt, source
     9187            FROM (
     9188                SELECT
     9189                    s.id AS id,
     9190                    s.content_id AS product_id,
     9191                    s.dt AS dt,
     9192                    'active' AS source
     9193                FROM `{$active_table}` s
     9194                WHERE s.content_type = %s
     9195                  AND s.id > %d
     9196                  AND s.id <= %d
     9197
     9198                UNION ALL
     9199
     9200                SELECT
     9201                    a.id AS id,
     9202                    a.content_id AS product_id,
     9203                    a.dt AS dt,
     9204                    'archive' AS source
     9205                FROM `{$archive_table}` a
     9206                WHERE a.content_type = %s
     9207                  AND a.id > %d
     9208                  AND a.id <= %d
     9209                  AND NOT EXISTS (
     9210                      SELECT 1
     9211                      FROM `{$active_table}` s
     9212                      WHERE s.id = a.id
     9213                        AND s.id <= %d
     9214                  )
     9215            ) AS combined
     9216            ORDER BY id ASC
     9217            LIMIT %d
     9218        ";
     9219
     9220        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is a fixed SQL template with value placeholders; execution below passes it through statpl_db_prepare() with the complete value list.
     9221        $rows = statpl_db_get_results(
     9222// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     9223            statpl_db_prepare(
     9224                /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $sql,
     9225                'cpt:product',
     9226                $last_processed_id,
     9227                $active_max_id,
     9228                'cpt:product',
     9229                $last_processed_id,
     9230                $archive_max_id,
     9231                $active_max_id,
     9232                $batch_size
     9233            ),
     9234            ARRAY_A
     9235        );
     9236
     9237        if (!empty($wpdb->last_error)) {
     9238            return [
     9239                'status' => 'database_error',
     9240                'rows'   => [],
     9241            ];
     9242        }
     9243
     9244        return [
     9245            'status' => 'available',
     9246            'rows'   => is_array($rows) ? $rows : [],
     9247        ];
     9248    }
     9249
     9250    /**
     9251     * Import one historical SlimStat product-view event idempotently.
     9252     *
     9253     * The event ledger is the unique source of truth. A guarded multi-table
     9254     * update applies the metrics and marks the event in one atomic statement.
     9255     *
     9256     * The caller is responsible for checkpoint progression and retry handling.
     9257     *
     9258     * @param array $row Historical SlimStat source row.
     9259     * @return string imported|duplicate|failed
     9260     */
     9261    private function import_slimstat_view_event($row) {
     9262        global $wpdb;
     9263
     9264        if (
     9265            !is_array($row)
     9266            || !isset($row['id'], $row['product_id'])
     9267        ) {
     9268            return 'failed';
     9269        }
     9270
     9271        $source_view_id = (int) $row['id'];
     9272        $product_id = (int) $row['product_id'];
     9273
     9274        if ($source_view_id <= 0 || $product_id <= 0) {
     9275            return 'failed';
     9276        }
     9277
     9278        $now = current_time('mysql');
     9279
     9280        // Insert the event as unapplied. Repeating this insert is harmless.
     9281        $ledger_inserted = statpl_db_query(
     9282            statpl_db_prepare(
     9283                "INSERT INTO {$this->view_import_events_table}
     9284                (source, source_view_id, product_id, imported_at, counts_applied)
     9285                VALUES (%s, %d, %d, %s, 0)
     9286                ON DUPLICATE KEY UPDATE
     9287                    source_view_id = VALUES(source_view_id)",
     9288                'slimstat',
     9289                $source_view_id,
     9290                $product_id,
     9291                $now
     9292            )
     9293        );
     9294
     9295        if (false === $ledger_inserted) {
     9296            return 'failed';
     9297        }
     9298
     9299        // Verify the existing event identity; never reassign it to another product.
     9300        $event = statpl_db_get_row(
     9301            statpl_db_prepare(
     9302                "SELECT product_id, counts_applied
     9303                FROM {$this->view_import_events_table}
     9304                WHERE source = %s AND source_view_id = %d",
     9305                'slimstat',
     9306                $source_view_id
     9307            ),
     9308            ARRAY_A
     9309        );
     9310
     9311        if (
     9312            !is_array($event)
     9313            || !isset($event['product_id'], $event['counts_applied'])
     9314            || (int) $event['product_id'] !== $product_id
     9315        ) {
     9316            return 'failed';
     9317        }
     9318
     9319        if (1 === (int) $event['counts_applied']) {
     9320            return 'duplicate';
     9321        }
     9322
     9323        // Ensure both metric rows exist. These writes are safe to repeat.
     9324        $total_ready = statpl_db_query(
     9325            statpl_db_prepare(
     9326                "INSERT INTO {$this->views_table}
     9327                (product_id, total_views)
     9328                VALUES (%d, 0)
     9329                ON DUPLICATE KEY UPDATE product_id = VALUES(product_id)",
     9330                $product_id
     9331            )
     9332        );
     9333
     9334        if (false === $total_ready) {
     9335            return 'failed';
     9336        }
     9337
     9338        $aggregate_ready = statpl_db_query(
     9339            statpl_db_prepare(
     9340                "INSERT INTO {$this->view_imports_table}
     9341                (source, product_id, imported_views, imported_at)
     9342                VALUES (%s, %d, 0, %s)
     9343                ON DUPLICATE KEY UPDATE product_id = VALUES(product_id)",
     9344                'slimstat',
     9345                $product_id,
     9346                $now
     9347            )
     9348        );
     9349
     9350        if (false === $aggregate_ready) {
     9351            return 'failed';
     9352        }
     9353
     9354        /*
     9355         * Apply all three changes atomically.
     9356         * Replaying this statement cannot increment the metrics twice.
     9357         */
     9358        $metrics_updated = statpl_db_query(
     9359            statpl_db_prepare(
     9360                "UPDATE {$this->views_table} t
     9361                INNER JOIN {$this->view_imports_table} i
     9362                    ON i.source = %s AND i.product_id = t.product_id
     9363                INNER JOIN {$this->view_import_events_table} e
     9364                    ON e.source = %s
     9365                    AND e.source_view_id = %d
     9366                    AND e.product_id = t.product_id
     9367                SET
     9368                    t.total_views = t.total_views + 1,
     9369                    i.imported_views = i.imported_views + 1,
     9370                    i.imported_at = %s,
     9371                    e.counts_applied = 1
     9372                WHERE t.product_id = %d
     9373                  AND e.counts_applied = 0",
     9374                'slimstat',
     9375                'slimstat',
     9376                $source_view_id,
     9377                $now,
     9378                $product_id
     9379            )
     9380        );
     9381
     9382        if (false === $metrics_updated) {
     9383            return 'failed';
     9384        }
     9385
     9386        if (3 === $metrics_updated) {
     9387            delete_transient('statpl_global_summary_v2');
     9388            return 'imported';
     9389        }
     9390
     9391        // Verify an ambiguous or concurrent result using the durable marker.
     9392        if (0 === $metrics_updated) {
     9393            $event = statpl_db_get_row(
     9394                statpl_db_prepare(
     9395                    "SELECT product_id, counts_applied
     9396                    FROM {$this->view_import_events_table}
     9397                    WHERE source = %s AND source_view_id = %d",
     9398                    'slimstat',
     9399                    $source_view_id
     9400                ),
     9401                ARRAY_A
     9402            );
     9403
     9404            if (
     9405                is_array($event)
     9406                && isset($event['product_id'], $event['counts_applied'])
     9407                && (int) $event['product_id'] === $product_id
     9408                && 1 === (int) $event['counts_applied']
     9409            ) {
     9410                delete_transient('statpl_global_summary_v2');
     9411                return 'duplicate';
     9412            }
     9413        }
     9414
     9415        return 'failed';
     9416    }
     9417
     9418
     9419    /** Persist an initial SlimStat import failure without counting a replay as another attempt. */
     9420    private function record_slimstat_view_import_failure($row, $error) {
     9421        global $wpdb;
     9422
     9423        $source_view_id = isset($row['id']) ? absint($row['id']) : 0;
     9424        $product_id = isset($row['product_id']) ? absint($row['product_id']) : 0;
     9425
     9426        if ($source_view_id < 1) {
     9427            return false;
     9428        }
     9429
     9430        $error = substr(wp_strip_all_tags((string) $error), 0, 4000);
     9431        if ($error === '') {
     9432            $error = 'SlimStat historical view could not be imported.';
     9433        }
     9434
     9435        $result = statpl_db_query(
     9436            statpl_db_prepare(
     9437                "INSERT INTO {$this->view_import_failures_table}
     9438                    (source, source_view_id, product_id, attempts, last_error, status, updated_at)
     9439                 VALUES (%s, %d, %d, 1, %s, 'retry', %s)
     9440                 ON DUPLICATE KEY UPDATE last_error = VALUES(last_error)",
     9441                'slimstat',
     9442                $source_view_id,
     9443                $product_id,
     9444                $error,
     9445                current_time('mysql')
     9446            )
     9447        );
     9448
     9449        if (false === $result) {
     9450            return false;
     9451        }
     9452        $this->save_historical_import_report_record('slimstat', 'SlimStat', 'slimstat', hash('sha256', "slimstat\0" . $source_view_id), 'retry', 1, 'record_import_failed');
     9453
     9454        $failure = statpl_db_get_row(
     9455            statpl_db_prepare(
     9456                "SELECT product_id
     9457                 FROM {$this->view_import_failures_table}
     9458                 WHERE source = %s AND source_view_id = %d",
     9459                'slimstat',
     9460                $source_view_id
     9461            ),
     9462            ARRAY_A
     9463        );
     9464
     9465        return empty($wpdb->last_error)
     9466            && is_array($failure)
     9467            && isset($failure['product_id'])
     9468            && (int) $failure['product_id'] === $product_id;
     9469    }
     9470
     9471    /** Process independently due SlimStat retries. */
     9472    private function process_slimstat_view_import_retries($lock = null) {
     9473        global $wpdb;
     9474
     9475        // Retry processing is part of the same serialized worker as the main
     9476        // batch. Verify ownership per event, not merely before and after the
     9477        // entire retry loop, because a transparent reconnect can occur midway.
     9478        if (!$this->slimstat_import_lock_is_current($lock)) {
     9479            return false;
     9480        }
     9481
     9482        $now = current_time('mysql');
     9483
     9484        $failures = statpl_db_get_results(
     9485            statpl_db_prepare(
     9486                "SELECT id, source_view_id, product_id, attempts
     9487                 FROM {$this->view_import_failures_table}
     9488                 WHERE source = %s
     9489                   AND status = 'retry'
     9490                   AND (
     9491                       (attempts = 1 AND updated_at <= DATE_SUB(%s, INTERVAL 1 MINUTE))
     9492                       OR
     9493                       (attempts = 2 AND updated_at <= DATE_SUB(%s, INTERVAL 2 MINUTE))
     9494                   )
     9495                 ORDER BY updated_at ASC
     9496                 LIMIT 50",
     9497                'slimstat',
     9498                $now,
     9499                $now
     9500            ),
     9501            ARRAY_A
     9502        );
     9503
     9504        if (!empty($wpdb->last_error) || !is_array($failures)) {
     9505            return false;
     9506        }
     9507
     9508        foreach ($failures as $failure) {
     9509            if (!$this->slimstat_import_lock_is_current($lock)) {
     9510                return false;
     9511            }
     9512
     9513            $result = $this->import_slimstat_view_event([
     9514                'id'         => (int) $failure['source_view_id'],
     9515                'product_id' => (int) $failure['product_id'],
     9516            ]);
     9517
     9518            // The event may have committed immediately before a DB reconnect.
     9519            // Its counts_applied marker makes replay safe, but no retry-ledger
     9520            // mutation may continue on a replacement session without the lock.
     9521            if (!$this->slimstat_import_lock_is_current($lock)) {
     9522                return false;
     9523            }
     9524
     9525            if (in_array($result, ['imported', 'duplicate'], true)) {
     9526                $this->save_historical_import_report_record('slimstat', 'SlimStat', 'slimstat', hash('sha256', "slimstat\0" . (int) $failure['source_view_id']), 'imported', (int) $failure['attempts'], '');
     9527                $deleted = statpl_db_delete(
     9528                    $this->view_import_failures_table,
     9529                    [
     9530                        'id'     => (int) $failure['id'],
     9531                        'source' => 'slimstat',
     9532                    ],
     9533                    ['%d', '%s']
     9534                );
     9535
     9536                if (false === $deleted || !$this->slimstat_import_lock_is_current($lock)) {
     9537                    return false;
     9538                }
     9539
     9540                continue;
     9541            }
     9542
     9543            $error = !empty($wpdb->last_error)
     9544                ? $wpdb->last_error
     9545                : 'SlimStat historical view retry failed.';
     9546
     9547            $updated = statpl_db_query(
     9548                statpl_db_prepare(
     9549                    "UPDATE {$this->view_import_failures_table}
     9550                     SET
     9551                         status = IF(attempts >= 2, 'failed', 'retry'),
     9552                         attempts = attempts + 1,
     9553                         last_error = %s,
     9554                         updated_at = %s
     9555                     WHERE id = %d
     9556                       AND source = %s
     9557                       AND status = 'retry'
     9558                       AND attempts < 3",
     9559                    substr(wp_strip_all_tags((string) $error), 0, 4000),
     9560                    current_time('mysql'),
     9561                    (int) $failure['id'],
     9562                    'slimstat'
     9563                )
     9564            );
     9565
     9566            if (1 !== $updated || !$this->slimstat_import_lock_is_current($lock)) {
     9567                return false;
     9568            }
     9569            $this->save_historical_import_report_record('slimstat', 'SlimStat', 'slimstat', hash('sha256', "slimstat\0" . (int) $failure['source_view_id']), ((int) $failure['attempts'] >= 2 ? 'failed' : 'retry'), (int) $failure['attempts'] + 1, 'record_import_failed');
     9570        }
     9571
     9572        return $this->slimstat_import_lock_is_current($lock);
     9573    }
     9574
     9575    /** Return the delay until the earliest pending SlimStat retry. */
     9576    private function get_slimstat_view_import_retry_delay() {
     9577        global $wpdb;
     9578
     9579        $delay = statpl_db_get_var(
     9580            statpl_db_prepare(
     9581                "SELECT GREATEST(
     9582                    5,
     9583                    TIMESTAMPDIFF(
     9584                        SECOND,
     9585                        %s,
     9586                        CASE
     9587                            WHEN attempts = 1 THEN DATE_ADD(updated_at, INTERVAL 1 MINUTE)
     9588                            ELSE DATE_ADD(updated_at, INTERVAL 2 MINUTE)
     9589                        END
     9590                    )
     9591                 )
     9592                 FROM {$this->view_import_failures_table}
     9593                 WHERE source = %s AND status = 'retry'
     9594                 ORDER BY
     9595                    CASE
     9596                        WHEN attempts = 1 THEN DATE_ADD(updated_at, INTERVAL 1 MINUTE)
     9597                        ELSE DATE_ADD(updated_at, INTERVAL 2 MINUTE)
     9598                    END ASC
     9599                 LIMIT 1",
     9600                current_time('mysql'),
     9601                'slimstat'
     9602            )
     9603        );
     9604
     9605        if (!empty($wpdb->last_error)) {
     9606            return 60;
     9607        }
     9608
     9609        return null === $delay ? 5 : max(5, (int) $delay);
     9610    }
     9611
     9612    /** Advance the SlimStat checkpoint only after the current row is durably resolved. */
     9613    private function update_slimstat_view_import_checkpoint($expected_id, $next_id, $lock) {
     9614        global $wpdb;
     9615
     9616        $expected_id = max(0, (int) $expected_id);
     9617        $next_id = max(0, (int) $next_id);
     9618
     9619        if ($next_id <= $expected_id) {
     9620            return $this->slimstat_import_lock_is_current($lock);
     9621        }
     9622        if (!$this->slimstat_import_lock_is_current($lock)) {
     9623            return false;
     9624        }
     9625
     9626        // Enforce ownership inside the checkpoint UPDATE itself. This closes
     9627        // the check/write race if wpdb reconnects between separate statements.
     9628        $updated = statpl_db_query(
     9629            statpl_db_prepare(
     9630                "UPDATE {$this->view_import_state_table}
     9631                 SET last_processed_id = %d, updated_at = %s
     9632                 WHERE source = %s AND last_processed_id = %d
     9633                   AND CONNECTION_ID() = %s
     9634                   AND IS_USED_LOCK(%s) = CONNECTION_ID()",
     9635                $next_id,
     9636                current_time('mysql'),
     9637                'slimstat',
     9638                $expected_id,
     9639                (string) $lock['connection_id'],
     9640                (string) $lock['lock_name']
     9641            )
     9642        );
     9643
     9644        return 1 === $updated && $this->slimstat_import_lock_is_current($lock);
     9645    }
     9646
     9647    /** Capture and verify the exact MySQL session that owns the SlimStat import lock. */
     9648    private function acquire_slimstat_import_lock($lock_name) {
     9649        global $wpdb;
     9650        try {
     9651            $row = statpl_db_get_row(statpl_db_prepare(
     9652                'SELECT CONNECTION_ID() AS connection_id, GET_LOCK(%s, 0) AS lock_acquired',
     9653                (string) $lock_name
     9654            ), ARRAY_A);
     9655        } catch (Throwable $exception) {
     9656            return ['acquired' => false, 'error' => $exception->getMessage()];
     9657        }
     9658        if (!is_array($row) || !empty($wpdb->last_error)
     9659            || (string) ($row['lock_acquired'] ?? '') !== '1'
     9660            || empty($row['connection_id'])) {
     9661            return ['acquired' => false, 'error' => (string) $wpdb->last_error];
     9662        }
     9663        return [
     9664            'acquired' => true,
     9665            'connection_id' => (string) $row['connection_id'],
     9666            'lock_name' => (string) $lock_name,
     9667        ];
     9668    }
     9669
     9670    /** Verify both connection identity and actual ownership of the SlimStat lock. */
     9671    private function slimstat_import_lock_is_current($lock) {
     9672        global $wpdb;
     9673        if (!is_array($lock) || empty($lock['acquired'])
     9674            || empty($lock['connection_id']) || empty($lock['lock_name'])) {
     9675            return false;
     9676        }
     9677        try {
     9678            $row = statpl_db_get_row(statpl_db_prepare(
     9679                'SELECT CONNECTION_ID() AS connection_id, IS_USED_LOCK(%s) AS lock_owner',
     9680                (string) $lock['lock_name']
     9681            ), ARRAY_A);
     9682        } catch (Throwable $exception) {
     9683            return false;
     9684        }
     9685        return is_array($row) && empty($wpdb->last_error)
     9686            && (string) ($row['connection_id'] ?? '') === (string) $lock['connection_id']
     9687            && (string) ($row['lock_owner'] ?? '') === (string) $lock['connection_id'];
     9688    }
     9689
     9690    /** Release the SlimStat lock only through the original lock-owning session. */
     9691    private function release_slimstat_import_lock($lock) {
     9692        global $wpdb;
     9693        if (!$this->slimstat_import_lock_is_current($lock)) {
     9694            if (defined('WP_DEBUG') && WP_DEBUG) {
     9695                $this->debug_log('Statora Plus: SlimStat advisory lock session changed before release.');
     9696            }
     9697            return false;
     9698        }
     9699        try {
     9700            $released = statpl_db_get_var(statpl_db_prepare(
     9701                'SELECT RELEASE_LOCK(%s)', (string) $lock['lock_name']
     9702            ));
     9703            return (string) $released === '1' && empty($wpdb->last_error);
     9704        } catch (Throwable $exception) {
     9705            if (defined('WP_DEBUG') && WP_DEBUG) {
     9706                $this->debug_log('Statora Plus: SlimStat advisory lock release failed: ' . $exception->getMessage());
     9707            }
     9708            return false;
     9709        }
     9710    }
     9711
     9712    /** Execute one protected SlimStat historical import batch. */
     9713    public function run_slimstat_view_import_batch() {
     9714        global $wpdb;
     9715
     9716        // MySQL advisory lock names must remain within the server's 64-character limit.
     9717        $lock_name = 'statpl_view_import_' . substr(md5($wpdb->prefix), 0, 32);
     9718
     9719        $slimstat_lock = $this->acquire_slimstat_import_lock($lock_name);
     9720
     9721        if (empty($slimstat_lock['acquired'])) {
     9722            $this->schedule_slimstat_view_import_batch(
     9723                !empty($wpdb->last_error) ? 60 : 15
     9724            );
     9725            return;
     9726        }
     9727
     9728        try {
     9729            if (!$this->slimstat_import_lock_is_current($slimstat_lock)) {
     9730                $this->schedule_slimstat_view_import_batch(60);
     9731                return;
     9732            }
     9733            $state = statpl_db_get_row(
     9734                statpl_db_prepare(
     9735                    "SELECT snapshot_max_id, last_processed_id, status
     9736                     FROM {$this->view_import_state_table}
     9737                     WHERE source = %s",
     9738                    'slimstat'
     9739                ),
     9740                ARRAY_A
     9741            );
     9742
     9743            if (!empty($wpdb->last_error)) {
     9744                $this->schedule_slimstat_view_import_batch(60);
     9745                return;
     9746            }
     9747
     9748            if (!is_array($state)) {
     9749                return;
     9750            }
     9751
     9752            if (!in_array($state['status'], ['pending', 'running'], true)) {
     9753                return;
     9754            }
     9755
     9756            $state_updated = statpl_db_query(
     9757                statpl_db_prepare(
     9758                    "UPDATE {$this->view_import_state_table}
     9759                     SET status = 'running', updated_at = %s
     9760                     WHERE source = %s AND status IN ('pending', 'running')
     9761                       AND CONNECTION_ID() = %s
     9762                       AND IS_USED_LOCK(%s) = CONNECTION_ID()",
     9763                    current_time('mysql'),
     9764                    'slimstat',
     9765                    (string) $slimstat_lock['connection_id'],
     9766                    (string) $slimstat_lock['lock_name']
     9767                )
     9768            );
     9769
     9770            if (false === $state_updated) {
     9771                $this->schedule_slimstat_view_import_batch(60);
     9772                return;
     9773            }
     9774
     9775            // Retry previously failed rows independently of the sequential source batch.
     9776            if (!$this->slimstat_import_lock_is_current($slimstat_lock)) {
     9777                $this->schedule_slimstat_view_import_batch(60);
     9778                return;
     9779            }
     9780            if (!$this->process_slimstat_view_import_retries($slimstat_lock)) {
     9781                $this->schedule_slimstat_view_import_batch(60);
     9782                return;
     9783            }
     9784            if (!$this->slimstat_import_lock_is_current($slimstat_lock)) {
     9785                // A retry may have committed its event before a transparent DB
     9786                // reconnect. Its durable event marker makes replay safe, but this
     9787                // worker must not advance the sequential checkpoint on a new session.
     9788                $this->schedule_slimstat_view_import_batch(60);
     9789                return;
     9790            }
     9791
     9792            $checkpoint = (int) $state['last_processed_id'];
     9793            $snapshot_max_id = (int) $state['snapshot_max_id'];
     9794
     9795            $batch = $this->get_slimstat_view_import_batch($checkpoint, 50);
     9796
     9797            if (
     9798                !is_array($batch)
     9799                || !isset($batch['status'], $batch['rows'])
     9800                || 'available' !== $batch['status']
     9801                || !is_array($batch['rows'])
     9802            ) {
     9803                $this->schedule_slimstat_view_import_batch(60);
     9804                return;
     9805            }
     9806
     9807            foreach ($batch['rows'] as $row) {
     9808                if (!$this->slimstat_import_lock_is_current($slimstat_lock)) {
     9809                    $this->schedule_slimstat_view_import_batch(60);
     9810                    return;
     9811                }
     9812                // A checkpoint replay must not consume another import attempt.
     9813                $existing_failure = statpl_db_get_row(
     9814                    statpl_db_prepare(
     9815                        "SELECT product_id, status
     9816                         FROM {$this->view_import_failures_table}
     9817                         WHERE source = %s AND source_view_id = %d",
     9818                        'slimstat',
     9819                        (int) $row['id']
     9820                    ),
     9821                    ARRAY_A
     9822                );
     9823
     9824                if (!empty($wpdb->last_error)) {
     9825                    $this->schedule_slimstat_view_import_batch(60);
     9826                    return;
     9827                }
     9828
     9829                if (is_array($existing_failure)) {
     9830                    if ((int) $existing_failure['product_id'] !== (int) $row['product_id']) {
     9831                        $this->debug_log(
     9832                            'Statora Plus: SlimStat failure ledger product ID mismatch for source event '
     9833                            . (int) $row['id']
     9834                        );
     9835                        $this->schedule_slimstat_view_import_batch(60);
     9836                        return;
     9837                    }
     9838
     9839                    if (!in_array($existing_failure['status'], ['retry', 'failed'], true)) {
     9840                        $this->schedule_slimstat_view_import_batch(60);
     9841                        return;
     9842                    }
     9843
     9844                    // The failure is already durable; wait for its independent retry.
     9845                    $next_id = (int) $row['id'];
     9846
     9847                    if (!$this->slimstat_import_lock_is_current($slimstat_lock)
     9848                        || !$this->update_slimstat_view_import_checkpoint($checkpoint, $next_id, $slimstat_lock)) {
     9849                        $this->schedule_slimstat_view_import_batch(60);
     9850                        return;
     9851                    }
     9852
     9853                    $checkpoint = $next_id;
     9854                    continue;
     9855                }
     9856
     9857                $result = $this->import_slimstat_view_event($row);
     9858
     9859                if (!$this->slimstat_import_lock_is_current($slimstat_lock)) {
     9860                    // The event helper may have completed its atomic metric update
     9861                    // just before a connection loss. Do not move the checkpoint;
     9862                    // replay is protected by counts_applied and the unique event key.
     9863                    $this->schedule_slimstat_view_import_batch(60);
     9864                    return;
     9865                }
     9866
     9867                if (in_array($result, ['imported', 'duplicate'], true)) {
     9868                    $this->save_historical_import_report_record('slimstat', 'SlimStat', 'slimstat', hash('sha256', "slimstat\0" . (int) $row['id']), 'imported', is_array($existing_failure) ? (int) ($existing_failure['attempts'] ?? 0) : 0, '');
     9869                    // A previous retry record is no longer needed after confirmed success.
     9870                    $deleted = statpl_db_delete(
     9871                        $this->view_import_failures_table,
     9872                        [
     9873                            'source'        => 'slimstat',
     9874                            'source_view_id' => (int) $row['id'],
     9875                        ],
     9876                        ['%s', '%d']
     9877                    );
     9878
     9879                    if (false === $deleted) {
     9880                        $this->schedule_slimstat_view_import_batch(60);
     9881                        return;
     9882                    }
     9883                } else {
     9884                    // Capture the helper's SQL error before executing another database query.
     9885                    $error = !empty($wpdb->last_error)
     9886                        ? $wpdb->last_error
     9887                        : 'SlimStat historical view could not be imported.';
     9888
     9889                    if (!$this->record_slimstat_view_import_failure($row, $error)) {
     9890                        $this->schedule_slimstat_view_import_batch(60);
     9891                        return;
     9892                    }
     9893                }
     9894
     9895                $next_id = (int) $row['id'];
     9896
     9897                if (!$this->slimstat_import_lock_is_current($slimstat_lock)
     9898                    || !$this->update_slimstat_view_import_checkpoint($checkpoint, $next_id, $slimstat_lock)) {
     9899                    $this->schedule_slimstat_view_import_batch(60);
     9900                    return;
     9901                }
     9902
     9903                $checkpoint = $next_id;
     9904            }
     9905
     9906            if (!empty($batch['rows'])) {
     9907                $this->schedule_slimstat_view_import_batch(5);
     9908                return;
     9909            }
     9910
     9911            // An empty successful source query means the snapshot has been exhausted.
     9912            if (!$this->slimstat_import_lock_is_current($slimstat_lock)) {
     9913                $this->schedule_slimstat_view_import_batch(60);
     9914                return;
     9915            }
     9916            if ($checkpoint < $snapshot_max_id) {
     9917                if (!$this->update_slimstat_view_import_checkpoint($checkpoint, $snapshot_max_id, $slimstat_lock)) {
     9918                    $this->schedule_slimstat_view_import_batch(60);
     9919                    return;
     9920                }
     9921
     9922                $checkpoint = $snapshot_max_id;
     9923            }
     9924
     9925            $failure_counts = statpl_db_get_row(
     9926                statpl_db_prepare(
     9927                    "SELECT
     9928                        SUM(CASE WHEN status = 'retry' THEN 1 ELSE 0 END) AS pending_retries,
     9929                        SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS terminal_failures
     9930                     FROM {$this->view_import_failures_table}
     9931                     WHERE source = %s",
     9932                    'slimstat'
     9933                ),
     9934                ARRAY_A
     9935            );
     9936
     9937            if (!empty($wpdb->last_error) || !is_array($failure_counts)) {
     9938                $this->schedule_slimstat_view_import_batch(60);
     9939                return;
     9940            }
     9941
     9942            $pending_retries = (int) $failure_counts['pending_retries'];
     9943            $terminal_failures = (int) $failure_counts['terminal_failures'];
     9944
     9945            if ($pending_retries > 0) {
     9946                $this->schedule_slimstat_view_import_batch(
     9947                    $this->get_slimstat_view_import_retry_delay()
     9948                );
     9949                return;
     9950            }
     9951
     9952            if (!$this->slimstat_import_lock_is_current($slimstat_lock)) {
     9953                $this->schedule_slimstat_view_import_batch(60);
     9954                return;
     9955            }
     9956            $final_status = $terminal_failures > 0 ? 'error' : 'complete';
     9957
     9958            $finalized = statpl_db_query(
     9959                statpl_db_prepare(
     9960                    "UPDATE {$this->view_import_state_table}
     9961                     SET status = %s, updated_at = %s
     9962                     WHERE source = %s AND status IN ('pending', 'running')
     9963                       AND CONNECTION_ID() = %s
     9964                       AND IS_USED_LOCK(%s) = CONNECTION_ID()",
     9965                    $final_status,
     9966                    current_time('mysql'),
     9967                    'slimstat',
     9968                    (string) $slimstat_lock['connection_id'],
     9969                    (string) $slimstat_lock['lock_name']
     9970                )
     9971            );
     9972
     9973            if (1 !== $finalized || !$this->slimstat_import_lock_is_current($slimstat_lock)) {
     9974                $this->schedule_slimstat_view_import_batch(60);
     9975            }
     9976        } finally {
     9977            // Release only through the original connection. If WordPress transparently
     9978            // reconnected, MySQL has already released the old session's advisory lock.
     9979            $this->release_slimstat_import_lock($slimstat_lock);
     9980        }
     9981    }
     9982
     9983    /** Schedule one independent SlimStat view import batch. */
     9984    public function schedule_slimstat_view_import_batch($delay = 10) {
     9985        global $wpdb;
     9986
     9987        $status = statpl_db_get_var(
     9988            statpl_db_prepare(
     9989                "SELECT status FROM {$this->view_import_state_table} WHERE source = %s",
     9990                'slimstat'
     9991            )
     9992        );
     9993
     9994        if (!empty($wpdb->last_error) || !in_array($status, ['pending', 'running'], true)) {
     9995            return;
     9996        }
     9997
     9998        $delay = max(5, (int) $delay);
     9999
     10000        if (function_exists('as_schedule_single_action') && !did_action('action_scheduler_init')) {
     10001            add_action('action_scheduler_init', [$this, 'schedule_slimstat_view_import_batch'], 10, 0);
     10002            return;
     10003        }
     10004
     10005        if (
     10006            did_action('action_scheduler_init')
     10007            && class_exists('ActionScheduler')
     10008            && class_exists('ActionScheduler_Store')
     10009            && null !== ActionScheduler::store()->query_action(
     10010                [
     10011                    'hook'    => 'statpl_view_import_batch',
     10012                    'status'  => ActionScheduler_Store::STATUS_PENDING,
     10013                    'group'   => 'statpl-plus',
     10014                    'args'    => [],
     10015                    'orderby' => 'none',
     10016                ]
     10017            )
     10018        ) {
     10019            return;
     10020        }
     10021
     10022        if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) {
     10023            $action_id = as_schedule_single_action(
     10024                time() + $delay,
     10025                'statpl_view_import_batch',
     10026                [],
     10027                'statpl-plus'
     10028            );
     10029
     10030            if ($action_id) {
     10031                return;
     10032            }
     10033        }
     10034
     10035        if (!wp_next_scheduled('statpl_view_import_batch')) {
     10036            wp_schedule_single_event(time() + $delay, 'statpl_view_import_batch');
     10037        }
     10038    }
     10039
    58010040    /** Schedule one import batch with a delay to avoid overlapping batch execution. */
    58110041    public function schedule_import_batch($delay = 10) {
    … …  
    64510105        $args = array_merge([$since,$since,$since], $ids);
    64610106        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query is prepared with all variable values; table name is a class property built from the WordPress table prefix and fixed suffix; event counts must be current.
    647         $rows = $wpdb->get_results($wpdb->prepare($sql, $args), ARRAY_A);
     10107        $rows = statpl_db_get_results(statpl_db_prepare($sql, $args), ARRAY_A);
    64810108        foreach ($rows as $r) {
    64910109            $id = (int)$r['product_id']; if (!isset($out[$id])) continue;
    … …  
    66210122        $event_args = array_merge([$event_since, $event_since], $ids);
    66310123        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query is prepared with all variable values; table name is a class property built from the WordPress table prefix and fixed suffix; event counts must be current.
    664         $event_rows = $wpdb->get_results($wpdb->prepare($event_sql, $event_args), ARRAY_A);
     10124        $event_rows = statpl_db_get_results(statpl_db_prepare($event_sql, $event_args), ARRAY_A);
    66510125        foreach ($event_rows as $event_row) {
    66610126            $id = (int)$event_row['product_id'];
    … …  
    68610146        $summary = [
    68710147            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This dashboard aggregate is cached by global_summary_metrics() for 60 seconds and invalidated when relevant data changes.
    688             'products' => (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type='product' AND post_status IN ('publish','private','draft','pending')"),
     10148            'products' => (int) statpl_db_get_var("SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type='product' AND post_status IN ('publish','private','draft','pending')"),
    68910149            'views' => 0, 'views24' => 0, 'orders' => 0, 'orders24' => 0,
    69010150            'units' => 0.0, 'units24' => 0.0, 'revenue' => 0.0, 'revenue24' => 0.0,
    … …  
    69410154        // All product views are recorded and aggregated by Statora Plus.
    69510155        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is constructor-generated from the WordPress prefix and a fixed suffix; the dashboard aggregate is cached for 60 seconds and view recording invalidates that cache.
    696         $summary['views'] = (int) $wpdb->get_var("SELECT COALESCE(SUM(total_views),0) FROM {$this->views_table}");
     10156        $summary['views'] = (int) statpl_db_get_var("SELECT COALESCE(SUM(total_views),0) FROM {$this->views_table}");
    69710157        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; the date value is prepared.
    698         $summary['views24'] = (int) $wpdb->get_var(
    699             $wpdb->prepare(
     10158        $summary['views24'] = (int) statpl_db_get_var(
     10159            statpl_db_prepare(
    70010160                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; the date value is prepared.
    70110161                "SELECT COUNT(*) FROM {$this->events_table} WHERE viewed_at >= %s",
    … …  
    70710167        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL identifiers cannot be bound as value placeholders; the identifier is internal, not user-controlled.
    70810168        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This aggregate is cached by global_summary_metrics() for 60 seconds and invalidated on relevant changes.
    709         $sales = $wpdb->get_row($wpdb->prepare("SELECT
     10169        $sales = statpl_db_get_row(statpl_db_prepare("SELECT
    71010170            COALESCE(SUM(CASE WHEN statpl_snapshot.order_status='completed' AND statpl_snapshot.completed_at IS NOT NULL THEN statpl_snapshot.net_quantity ELSE 0 END),0) AS units_total,
    71110171            COALESCE(SUM(CASE WHEN statpl_snapshot.order_status='completed' AND statpl_snapshot.completed_at >= %s THEN statpl_snapshot.net_quantity ELSE 0 END),0) AS units24,
    … …  
    72310183        // Product-level detail is calculated separately from rows with actual product IDs.
    72410184        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; no user-supplied SQL values are used.
    725         $event_totals = $wpdb->get_row(
     10185        $event_totals = statpl_db_get_row(
    72610186            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; this aggregate has no user-supplied SQL values.
    72710187            "SELECT COUNT(DISTINCT CASE WHEN event_type='cancelled' AND product_id=0 THEN order_id END) AS cancelled_total, COUNT(DISTINCT CASE WHEN event_type='refund' AND product_id=0 AND event_ref_id > 0 THEN event_ref_id END) AS refunds_total FROM {$this->order_events_table}",
    … …  
    73410194        // Every normal order is represented once per product in the snapshot; count order IDs only once.
    73510195        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is constructor-generated from the WordPress prefix and a fixed suffix.; Table identifier is internally generated from the WordPress prefix and a fixed suffix.; This aggregate is part of the 60-second cached dashboard summary.
    736         $summary['total_orders'] = (int) $wpdb->get_var("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table}");
     10196        $summary['total_orders'] = (int) statpl_db_get_var("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table}");
    73710197        // Clean orders are currently processing/completed and retain some non-refunded product quantity/value.
    73810198        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is constructor-generated from the WordPress prefix and a fixed suffix.; Table identifier is internally generated from the WordPress prefix and a fixed suffix.; This aggregate is part of the 60-second cached dashboard summary.
    739         $summary['orders'] = (int) $wpdb->get_var("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table} WHERE order_status IN ('processing','completed') AND (net_quantity > 0 OR net_revenue > 0)");
     10199        $summary['orders'] = (int) statpl_db_get_var("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table} WHERE order_status IN ('processing','completed') AND (net_quantity > 0 OR net_revenue > 0)");
    74010200        // Today's clean orders are based on order creation date, not payment/completion date.
    74110201        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is internally generated; date value is prepared.; This aggregate is part of the 60-second cached dashboard summary.
    742         $summary['orders24'] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table} WHERE order_status IN ('processing','completed') AND created_at >= %s AND (net_quantity > 0 OR net_revenue > 0)", $since));
     10202        $summary['orders24'] = (int) statpl_db_get_var(statpl_db_prepare("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table} WHERE order_status IN ('processing','completed') AND created_at >= %s AND (net_quantity > 0 OR net_revenue > 0)", $since));
    74310203        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is internally generated; date value is prepared.; This aggregate is part of the 60-second cached dashboard summary.
    744         $summary['cancelled24'] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT order_id) FROM {$this->order_events_table} WHERE product_id=0 AND event_type='cancelled' AND event_at >= %s", $since));
     10204        $summary['cancelled24'] = (int) statpl_db_get_var(statpl_db_prepare("SELECT COUNT(DISTINCT order_id) FROM {$this->order_events_table} WHERE product_id=0 AND event_type='cancelled' AND event_at >= %s", $since));
    74510205        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is internally generated; date value is prepared.; This aggregate is part of the 60-second cached dashboard summary.
    746         $summary['refunds24'] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT event_ref_id) FROM {$this->order_events_table} WHERE product_id=0 AND event_type='refund' AND event_ref_id > 0 AND event_at >= %s", $since));
     10206        $summary['refunds24'] = (int) statpl_db_get_var(statpl_db_prepare("SELECT COUNT(DISTINCT event_ref_id) FROM {$this->order_events_table} WHERE product_id=0 AND event_type='refund' AND event_ref_id > 0 AND event_at >= %s", $since));
    74710207        $summary['revenue'] = round($summary['revenue'], wc_get_price_decimals());
    74810208        $summary['revenue24'] = round($summary['revenue24'], wc_get_price_decimals());
    … …  
    82910289                        SELECT product_id, COUNT(*) AS views_today
    83010290                        FROM {$this->events_table}
    831                         WHERE viewed_at >= " . $wpdb->prepare('%s', $since_mysql) . "
     10291                        WHERE viewed_at >= " . statpl_db_prepare('%s', $since_mysql) . "
    83210292                        GROUP BY product_id
    83310293                    ) recent ON recent.product_id = totals.product_id
    … …  
    83710297                    SELECT product_id, COUNT(*) AS views_today
    83810298                    FROM {$this->events_table}
    839                     WHERE viewed_at >= " . $wpdb->prepare('%s', $since_mysql) . "
     10299                    WHERE viewed_at >= " . statpl_db_prepare('%s', $since_mysql) . "
    84010300                    GROUP BY product_id
    84110301                ) statpl_recent_views ON statpl_recent_views.product_id = p.ID";
    … …  
    84910309                    SELECT product_id,
    85010310                        COUNT(DISTINCT CASE WHEN order_status IN ('processing','completed') AND (net_quantity > 0 OR net_revenue > 0) THEN order_id END) AS orders,
    851                         COUNT(DISTINCT CASE WHEN order_status IN ('processing','completed') AND created_at >= " . $wpdb->prepare('%s', $since) . " AND (net_quantity > 0 OR net_revenue > 0) THEN order_id END) AS orders24,
     10311                        COUNT(DISTINCT CASE WHEN order_status IN ('processing','completed') AND created_at >= " . statpl_db_prepare('%s', $since) . " AND (net_quantity > 0 OR net_revenue > 0) THEN order_id END) AS orders24,
    85210312                        SUM(CASE WHEN order_status='completed' AND completed_at IS NOT NULL THEN net_quantity ELSE 0 END) AS units,
    853                         SUM(CASE WHEN order_status='completed' AND completed_at >= " . $wpdb->prepare('%s', $since) . " THEN net_quantity ELSE 0 END) AS units24,
     10313                        SUM(CASE WHEN order_status='completed' AND completed_at >= " . statpl_db_prepare('%s', $since) . " THEN net_quantity ELSE 0 END) AS units24,
    85410314                        SUM(CASE WHEN order_status='completed' AND completed_at IS NOT NULL THEN net_revenue ELSE 0 END) AS revenue,
    855                         SUM(CASE WHEN order_status='completed' AND completed_at >= " . $wpdb->prepare('%s', $since) . " THEN net_revenue ELSE 0 END) AS revenue24
     10315                        SUM(CASE WHEN order_status='completed' AND completed_at >= " . statpl_db_prepare('%s', $since) . " THEN net_revenue ELSE 0 END) AS revenue24
    85610316                    FROM {$this->order_items_table} GROUP BY product_id
    85710317                ) statpl_orders ON statpl_orders.product_id = p.ID
    … …  
    85910319                    SELECT product_id,
    86010320                        COUNT(DISTINCT CASE WHEN event_type='cancelled' THEN order_id END) AS cancelled,
    861                         COUNT(DISTINCT CASE WHEN event_type='cancelled' AND event_at >= " . $wpdb->prepare('%s', $since) . " THEN order_id END) AS cancelled24,
     10321                        COUNT(DISTINCT CASE WHEN event_type='cancelled' AND event_at >= " . statpl_db_prepare('%s', $since) . " THEN order_id END) AS cancelled24,
    86210322                        COUNT(DISTINCT CASE WHEN event_type='refund' AND event_ref_id > 0 THEN event_ref_id END) AS refunds,
    863                         COUNT(DISTINCT CASE WHEN event_type='refund' AND event_ref_id > 0 AND event_at >= " . $wpdb->prepare('%s', $since) . " THEN event_ref_id END) AS refunds24
     10323                        COUNT(DISTINCT CASE WHEN event_type='refund' AND event_ref_id > 0 AND event_at >= " . statpl_db_prepare('%s', $since) . " THEN event_ref_id END) AS refunds24
    86410324                    FROM {$this->order_events_table} GROUP BY product_id
    86510325                ) statpl_events ON statpl_events.product_id = p.ID";
    … …  
    86910329            // $join and $where are assembled only from fixed SQL fragments and prepared placeholders; user input is passed in $args.
    87010330            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query fragments are plugin-controlled; all user-supplied values are bound through prepare(); product filters are evaluated live.
    871             $total = (int) $wpdb->get_var($wpdb->prepare($count_sql, $args));
     10331            $total = (int) statpl_db_get_var(statpl_db_prepare($count_sql, $args));
    87210332            $order_column = $order_columns[$orderby] ?? 'p.post_title';
    87310333            $pages_for_query = max(1, (int) ceil($total / $per_page));
    … …  
    87810338            // $order_column is selected from the internal allowlist and $order is normalized before query construction.
    87910339            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query fragments are plugin-controlled; all user-supplied values are bound through prepare(); product filters are evaluated live.
    880             $product_ids = array_map('intval', $wpdb->get_col($wpdb->prepare($id_sql, $id_args)));
     10340            $product_ids = array_map('intval', statpl_db_get_col(statpl_db_prepare($id_sql, $id_args)));
    88110341        } else {
    88210342            $base_args = ['post_type'=>'product','post_status'=>$statuses,'posts_per_page'=>-1,'fields'=>'ids','orderby'=>'ID','order'=>'ASC'];
    … …  
    89110351if (false === $sku_ids) {
    89210352    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Prepared partial-SKU lookup is required; results are cached for 60 seconds.
    893     $sku_ids = $wpdb->get_col(
    894         $wpdb->prepare(
     10353    $sku_ids = statpl_db_get_col(
     10354        statpl_db_prepare(
    89510355    "SELECT DISTINCT p.ID
    89610356    FROM {$wpdb->posts} p
    … …  
    92810388            $st = $stats_by_product[$product_id] ?? ['orders'=>0,'orders24'=>0,'units'=>0,'units24'=>0,'revenue'=>0.0,'revenue24'=>0.0,'cancelled'=>0,'cancelled24'=>0,'refunds'=>0,'refunds24'=>0];
    92910389            $stock_summary = $this->stock_summary_for_product($product);
     10390            $product_status = get_post_status($product_id);
     10391            $visibility_labels = [
     10392                'private' => 'Κρυφό',
     10393                'draft' => 'Πρόχειρο',
     10394                'pending' => 'Σε αναμονή',
     10395            ];
    93010396            $rows[] = ['id'=>$product_id,'title'=>$product->get_name(),'date'=>get_post_field('post_date',$product_id),'sku'=>$product->get_sku(),
    931                 'views'=>(int)$views['total'],'views24'=>(int)$views['today'],'source'=>$views['source'],'stock_managed'=>$stock_summary['managed'],'stock'=>$stock_summary['quantity'],'edit_url'=>get_edit_post_link($product_id, 'raw')] + $st;
     10397                'product_visibility'=>$visibility_labels[$product_status] ?? '',
     10398                'views'=>(int)$views['total'],'views24'=>(int)$views['today'],'source'=>$views['source'],'stock_managed'=>$stock_summary['managed'],'stock'=>$stock_summary['quantity'],'stock_in_stock'=>$stock_summary['in_stock'],'edit_url'=>get_edit_post_link($product_id, 'raw')] + $st;
    93210399        }
    93310400        if (!$db_paged) {
    … …  
    94510412        $summary = $this->global_summary_metrics();
    94610413
    947         echo '<div class="wrap statpl-metrics-wrap"><h1>Statora Plus</h1><p class="description">Στατιστικά προϊόντων WooCommerce σε μία συνοπτική εικόνα.</p>';
     10414        echo '<div class="wrap statpl-metrics-wrap"><h1>Statora Plus</h1>';
    94810415        $overview_url = add_query_arg(['page'=>'statpl-product-metrics','view'=>'overview'], admin_url('admin.php'));
    94910416        $products_url = add_query_arg(['page'=>'statpl-product-metrics','view'=>'products'], admin_url('admin.php'));
    95010417        echo '<nav class="nav-tab-wrapper"><a class="nav-tab '.($view === 'overview' ? 'nav-tab-active' : '').'" href="'.esc_url($overview_url).'">Συνολική εικόνα</a><a class="nav-tab '.($view === 'products' ? 'nav-tab-active' : '').'" href="'.esc_url($products_url).'">Στατιστικά ανά προϊόν</a></nav>';
    95110418
    952         if (get_option('statpl_import_complete')) {
    953             echo '<div class="notice notice-success inline"><p>Η αρχική εισαγωγή ιστορικού έχει επισημανθεί ως ολοκληρωμένη.</p></div>';
    954         } else {
    955             $import_page = max(1, (int) get_option('statpl_import_page', 1));
    956             echo '<div class="notice notice-info inline"><p>Η αρχική εισαγωγή ιστορικού εκτελείται στο παρασκήνιο. Επεξεργασία παρτίδας: '.esc_html((string)$import_page).'. Τα στατιστικά ενδέχεται να είναι ελλιπή μέχρι να ολοκληρωθεί.</p></div>';
    957         }
     10419        $this->render_historical_import_notice();
    95810420        if ($view === 'overview') {
    95910421            echo '<h2>Συνολικά στατιστικά καταστήματος</h2>';
    … …  
    100610468        if (!$visible_rows) echo '<tr><td colspan="14">Δεν βρέθηκαν προϊόντα.</td></tr>';
    100710469        foreach ($visible_rows as $r) {
    1008             echo '<tr><td><a class="statpl-product-title" href="'.esc_url($r['edit_url']).'">'.esc_html($r['title']).'</a></td>';
    1009             echo '<td>'.($r['stock_managed'] ? esc_html(number_format_i18n($r['stock'])) : '<span class="statpl-stock-not-tracked">Δεν παρακολουθείται</span>').'</td>';
     10470            echo '<tr><td><a class="statpl-product-title" href="'.esc_url($r['edit_url']).'">'.esc_html($r['title']).'</a>';
     10471            if (!empty($r['product_visibility'])) {
     10472                echo ' <span class="statpl-product-visibility">'.esc_html($r['product_visibility']).'</span>';
     10473            }
     10474            echo '</td>';
     10475            if ($r['stock_managed']) {
     10476                $stock_quantity = (float) $r['stock'];
     10477                if ($stock_quantity > 0) {
     10478                    echo '<td>'.esc_html(number_format_i18n($stock_quantity)).'</td>';
     10479                } elseif (!empty($r['stock_in_stock'])) {
     10480                    // Zero quantity with backorders allowed remains purchasable.
     10481                    echo '<td>0</td>';
     10482                } else {
     10483                    echo '<td>Εξαντλημένο</td>';
     10484                }
     10485            } else {
     10486                echo '<td><span class="statpl-stock-not-tracked">Δεν γίνεται διαχείριση αποθέματος</span></td>';
     10487            }
    101010488            echo '<td>'.esc_html(number_format_i18n($r['views'])).'</td><td>'.esc_html(number_format_i18n($r['views24'])).'</td>';
    101110489            foreach (['orders','orders24'] as $k) echo '<td>'.esc_html(number_format_i18n($r[$k])).'</td>';
    … …  
    102210500}
    102310501STATPL_Core::instance();
     10502
     10503
     10504/**
     10505 * Page View Count adapter.
     10506 *
     10507 * Reads only the provider's immutable, per-product daily rows from pvc_daily.
     10508 * The current day is intentionally excluded because Page View Count updates
     10509 * that row in place; importing a mutable count would violate the immutable
     10510 * source snapshot contract. Lifetime totals in pvc_total are deliberately not
     10511 * imported because they overlap the daily rows and have no date boundaries.
     10512 */
     10513if (!class_exists('STATPL_Page_View_Count_Historical_Adapter')) {
     10514    final class STATPL_Page_View_Count_Historical_Adapter implements STATPL_Historical_View_Adapter {
     10515        const ADAPTER_KEY = 'page_view_count';
     10516        const SOURCE_KEY = 'page_view_count';
     10517        const ADAPTER_VERSION = '1.0.0';
     10518        const ORIGIN_KEY = 'daily_product_views';
     10519
     10520        public function get_adapter_key() {
     10521            return self::ADAPTER_KEY;
     10522        }
     10523
     10524        public function get_source_key() {
     10525            return self::SOURCE_KEY;
     10526        }
     10527
     10528        public function get_display_name() {
     10529            return 'Page Views Count';
     10530        }
     10531
     10532        public function get_adapter_version() {
     10533            return self::ADAPTER_VERSION;
     10534        }
     10535
     10536        private function table_name() {
     10537            global $wpdb;
     10538            return $wpdb->prefix . 'pvc_daily';
     10539        }
     10540
     10541        private function quoted_table_name() {
     10542            $table = $this->table_name();
     10543            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     10544                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     10545            }
     10546            return '`' . $table . '`';
     10547        }
     10548
     10549        private function inspect_table() {
     10550            global $wpdb;
     10551            $table = $this->table_name();
     10552            // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- SHOW TABLES LIKE has one %s placeholder and receives exactly one escaped table-name value.
     10553            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     10554            if (!empty($wpdb->last_error)) {
     10555                return ['status' => 'database_error', 'error' => (string) $wpdb->last_error];
     10556            }
     10557            if ($exists !== $table) {
     10558                return ['status' => 'missing_table'];
     10559            }
     10560
     10561            // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- SHOW COLUMNS contains no value placeholders; the table identifier is validated and quoted by quoted_table_name().
     10562// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     10563            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A);
     10564            if (!empty($wpdb->last_error) || !is_array($columns)) {
     10565                return ['status' => 'database_error', 'error' => (string) $wpdb->last_error];
     10566            }
     10567            $names = [];
     10568            foreach ($columns as $column) {
     10569                if (isset($column['Field'])) {
     10570                    $names[] = (string) $column['Field'];
     10571                }
     10572            }
     10573            $required = ['id', 'time', 'postnum', 'postcount'];
     10574            if (array_diff($required, $names)) {
     10575                return ['status' => 'incompatible_schema'];
     10576            }
     10577            return ['status' => 'available'];
     10578        }
     10579
     10580        public function should_register_adapter() {
     10581            global $wpdb;
     10582            $inspection = $this->inspect_table();
     10583            if ($inspection['status'] !== 'missing_table') { return true; }
     10584            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     10585            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     10586                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     10587            }
     10588            $registry_table = '`' . $registry_name . '`';
     10589            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     10590            if (!empty($wpdb->last_error)) { return true; }
     10591            if ($registry_exists !== $registry_name) { return false; }
     10592            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     10593// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     10594            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     10595            return !empty($wpdb->last_error) || !empty($source_id);
     10596        }
     10597
     10598        public function discover_origins() {
     10599            $inspection = $this->inspect_table();
     10600            if ($inspection['status'] === 'missing_table') {
     10601                // A previously registered source can be marked unavailable by the
     10602                // core's authoritative inventory reconciliation if its table was
     10603                // removed. A never-installed source is not registered needlessly.
     10604                return [];
     10605            }
     10606            return [[
     10607                'origin_key' => self::ORIGIN_KEY,
     10608                'capabilities' => [
     10609                    'product_views' => true,
     10610                    'granularity' => 'daily_aggregate',
     10611                    'immutable_past_days' => true,
     10612                ],
     10613                'detection_status' => $inspection['status'],
     10614            ]];
     10615        }
     10616
     10617        public function get_origin_snapshot($origin_key) {
     10618            global $wpdb;
     10619            if ($origin_key !== self::ORIGIN_KEY) {
     10620                return [];
     10621            }
     10622            $inspection = $this->inspect_table();
     10623            if ($inspection['status'] !== 'available') {
     10624                throw new RuntimeException('Page View Count source table is not available or has an incompatible schema.');
     10625            }
     10626
     10627            $through_date = current_datetime()->modify('-1 day')->format('Y-m-d');
     10628            $posts_table_name = $wpdb->posts;
     10629            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     10630                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     10631            }
     10632            $posts_table = '`' . $posts_table_name . '`';
     10633// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     10634            $max_id = statpl_db_get_var(statpl_db_prepare(
     10635                'SELECT MAX(d.id)
     10636                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS d
     10637                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p
     10638                   ON p.ID = CAST(d.postnum AS UNSIGNED)
     10639                  AND p.post_type = %s
     10640                 WHERE d.postnum REGEXP %s AND d.time <= %s',
     10641                'product',
     10642                '^[0-9]+$',
     10643                $through_date
     10644            ));
     10645            if (!empty($wpdb->last_error)) {
     10646                throw new RuntimeException('Page View Count snapshot query failed.');
     10647            }
     10648
     10649            return [
     10650                'boundary' => [
     10651                    'max_id' => max(0, (int) $max_id),
     10652                    'through_date' => $through_date,
     10653                ],
     10654                'initial_checkpoint' => null,
     10655                'captured_at' => current_time('mysql'),
     10656            ];
     10657        }
     10658
     10659        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     10660            global $wpdb;
     10661            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     10662                throw new RuntimeException('Invalid Page View Count origin or snapshot.');
     10663            }
     10664            $inspection = $this->inspect_table();
     10665            if ($inspection['status'] !== 'available') {
     10666                throw new RuntimeException('Page View Count source table became unavailable or incompatible.');
     10667            }
     10668
     10669            $boundary = $snapshot['boundary'];
     10670            $max_id = isset($boundary['max_id']) ? absint($boundary['max_id']) : -1;
     10671            $through_date = isset($boundary['through_date']) ? (string) $boundary['through_date'] : '';
     10672            $date = DateTime::createFromFormat('!Y-m-d', $through_date);
     10673            if ($max_id < 0 || !$date || $date->format('Y-m-d') !== $through_date) {
     10674                throw new RuntimeException('Invalid Page View Count snapshot boundary.');
     10675            }
     10676
     10677            $cursor_id = 0;
     10678            if (is_array($checkpoint) && isset($checkpoint['cursor'])) {
     10679                $cursor_value = $checkpoint['cursor'];
     10680                if ($cursor_value !== null) {
     10681                    $cursor_id = absint($cursor_value);
     10682                }
     10683            } elseif (is_numeric($checkpoint)) {
     10684                $cursor_id = absint($checkpoint);
     10685            }
     10686
     10687            $limit = max(1, min(100, absint($limit)));
     10688            $posts_table_name = $wpdb->posts;
     10689            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     10690                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     10691            }
     10692            $posts_table = '`' . $posts_table_name . '`';
     10693// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     10694            $rows = statpl_db_get_results(statpl_db_prepare(
     10695                'SELECT MAX(d.id) AS id, d.time, d.postnum, SUM(d.postcount) AS postcount
     10696                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS d
     10697                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p
     10698                   ON p.ID = CAST(d.postnum AS UNSIGNED)
     10699                  AND p.post_type = %s
     10700                 WHERE d.postnum REGEXP %s
     10701                   AND d.id <= %d
     10702                   AND d.time <= %s
     10703                 GROUP BY d.time, d.postnum
     10704                 HAVING MAX(d.id) > %d
     10705                 ORDER BY MAX(d.id) ASC
     10706                 LIMIT %d',
     10707                'product',
     10708                '^[0-9]+$',
     10709                $max_id,
     10710                $through_date,
     10711                $cursor_id,
     10712                $limit + 1
     10713            ), ARRAY_A);
     10714            if (!empty($wpdb->last_error) || !is_array($rows)) {
     10715                throw new RuntimeException('Page View Count batch query failed.');
     10716            }
     10717
     10718            $has_more = count($rows) > $limit;
     10719            if ($has_more) {
     10720                $rows = array_slice($rows, 0, $limit);
     10721            }
     10722            $next_checkpoint = $cursor_id;
     10723            if ($rows) {
     10724                $last = end($rows);
     10725                $next_checkpoint = absint($last['id']);
     10726            }
     10727
     10728            return [
     10729                'rows' => array_values($rows),
     10730                'has_more' => $has_more,
     10731                'next_checkpoint' => $next_checkpoint,
     10732                'boundary_applied' => true,
     10733            ];
     10734        }
     10735
     10736        public function normalize_record($origin_key, $record) {
     10737            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) {
     10738                return null;
     10739            }
     10740            $record_id = isset($record['id']) ? absint($record['id']) : 0;
     10741            $product_raw = isset($record['postnum']) ? (string) $record['postnum'] : '';
     10742            $views_raw = $record['postcount'] ?? null;
     10743            $date = isset($record['time']) ? (string) $record['time'] : '';
     10744            if (!$record_id || !preg_match('/^[1-9][0-9]*$/D', $product_raw) || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) {
     10745                return null;
     10746            }
     10747            $parsed_date = DateTime::createFromFormat('!Y-m-d', $date);
     10748            $errors = DateTime::getLastErrors();
     10749            if (!$parsed_date || $parsed_date->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) {
     10750                return null;
     10751            }
     10752
     10753            $product_id = (int) $product_raw;
     10754            $views = (int) $views_raw;
     10755            $period_start = $date . ' 00:00:00';
     10756            $period_end = $date . ' 23:59:59';
     10757
     10758            return [
     10759                'product_id' => $product_id,
     10760                'source_record_key' => 'pvc_daily_' . $product_id . '_' . $date,
     10761                'views' => $views,
     10762                'record_type' => 'aggregate',
     10763                'period_start' => $period_start,
     10764                'period_end' => $period_end,
     10765                'cursor' => $record_id,
     10766            ];
     10767        }
     10768    }
     10769}
     10770
     10771add_filter('statpl_historical_view_adapters', static function ($adapters) {
     10772    if (!is_array($adapters)) {
     10773        return $adapters;
     10774    }
     10775    $adapter = new STATPL_Page_View_Count_Historical_Adapter();
     10776    if ($adapter->should_register_adapter()) { $adapters[] = $adapter; }
     10777    return $adapters;
     10778}, 30);
     10779
     10780/**
     10781 * WP Statistics adapter.
     10782 *
     10783 * Imports daily rows from statistics_pages where WP Statistics has explicitly
     10784 * classified the viewed resource as a WooCommerce product. The historical
     10785 * summary table is not read here: it no longer preserves a trustworthy date
     10786 * interval after detailed rows have been aggregated.
     10787 */
     10788if (!class_exists('STATPL_WP_Statistics_Historical_Adapter')) {
     10789    final class STATPL_WP_Statistics_Historical_Adapter implements STATPL_Historical_View_Adapter {
     10790        const ADAPTER_KEY = 'wp_statistics_pages';
     10791        const SOURCE_KEY = 'wp_statistics';
     10792        const ADAPTER_VERSION = '1.0.0';
     10793        const ORIGIN_KEY = 'product_daily_pages';
     10794
     10795        public function get_adapter_key() { return self::ADAPTER_KEY; }
     10796        public function get_source_key() { return self::SOURCE_KEY; }
     10797        public function get_display_name() { return 'WP Statistics'; }
     10798        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     10799
     10800        private function table_name() {
     10801            global $wpdb;
     10802            return $wpdb->prefix . 'statistics_pages';
     10803        }
     10804
     10805        private function quoted_table_name() {
     10806            $table = $this->table_name();
     10807            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     10808                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     10809            }
     10810            return '`' . $table . '`';
     10811        }
     10812
     10813        private function inspect_table() {
     10814            global $wpdb;
     10815            $table = $this->table_name();
     10816            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     10817            if (!empty($wpdb->last_error)) {
     10818                return ['status' => 'database_error', 'error' => (string) $wpdb->last_error];
     10819            }
     10820            if ($exists !== $table) {
     10821                return ['status' => 'missing_table'];
     10822            }
     10823// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     10824            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A);
     10825            if (!empty($wpdb->last_error) || !is_array($columns)) {
     10826                return ['status' => 'database_error', 'error' => (string) $wpdb->last_error];
     10827            }
     10828            $names = [];
     10829            foreach ($columns as $column) {
     10830                if (isset($column['Field'])) { $names[] = (string) $column['Field']; }
     10831            }
     10832            if (array_diff(['page_id', 'uri', 'type', 'date', 'count', 'id'], $names)) {
     10833                return ['status' => 'incompatible_schema'];
     10834            }
     10835            return ['status' => 'available'];
     10836        }
     10837
     10838        public function should_register_adapter() {
     10839            global $wpdb;
     10840            $inspection = $this->inspect_table();
     10841            if ($inspection['status'] !== 'missing_table') { return true; }
     10842            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     10843            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     10844                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     10845            }
     10846            $registry_table = '`' . $registry_name . '`';
     10847            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     10848            if (!empty($wpdb->last_error)) { return true; }
     10849            if ($registry_exists !== $registry_name) { return false; }
     10850            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     10851// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     10852            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     10853            return !empty($wpdb->last_error) || !empty($source_id);
     10854        }
     10855
     10856        public function discover_origins() {
     10857            $inspection = $this->inspect_table();
     10858            if ($inspection['status'] === 'missing_table') { return []; }
     10859            return [[
     10860                'origin_key' => self::ORIGIN_KEY,
     10861                'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'immutable_past_days' => true],
     10862                'detection_status' => $inspection['status'],
     10863            ]];
     10864        }
     10865
     10866        public function get_origin_snapshot($origin_key) {
     10867            global $wpdb;
     10868            if ($origin_key !== self::ORIGIN_KEY) { return []; }
     10869            if ($this->inspect_table()['status'] !== 'available') {
     10870                throw new RuntimeException('WP Statistics pages table is unavailable or incompatible.');
     10871            }
     10872            $through_date = current_datetime()->modify('-1 day')->format('Y-m-d');
     10873            $posts_table_name = $wpdb->posts;
     10874            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     10875                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     10876            }
     10877            $posts_table = '`' . $posts_table_name . '`';
     10878// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     10879            $max_id = statpl_db_get_var(statpl_db_prepare(
     10880                'SELECT MAX(s.page_id)
     10881                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s
     10882                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.id AND p.post_type = %s
     10883                 WHERE s.type = %s AND s.date <= %s',
     10884                'product', 'product', $through_date
     10885            ));
     10886            if (!empty($wpdb->last_error)) {
     10887                throw new RuntimeException('WP Statistics snapshot query failed.');
     10888            }
     10889            return [
     10890                'boundary' => ['max_page_id' => max(0, (int) $max_id), 'through_date' => $through_date],
     10891                'initial_checkpoint' => null,
     10892                'captured_at' => current_time('mysql'),
     10893            ];
     10894        }
     10895
     10896        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     10897            global $wpdb;
     10898            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     10899                throw new RuntimeException('Invalid WP Statistics origin or snapshot.');
     10900            }
     10901            if ($this->inspect_table()['status'] !== 'available') {
     10902                throw new RuntimeException('WP Statistics pages table became unavailable or incompatible.');
     10903            }
     10904            $boundary = $snapshot['boundary'];
     10905            $max_id = isset($boundary['max_page_id']) ? absint($boundary['max_page_id']) : -1;
     10906            $through_date = isset($boundary['through_date']) ? (string) $boundary['through_date'] : '';
     10907            $parsed = DateTime::createFromFormat('!Y-m-d', $through_date);
     10908            if ($max_id < 0 || !$parsed || $parsed->format('Y-m-d') !== $through_date) {
     10909                throw new RuntimeException('Invalid WP Statistics snapshot boundary.');
     10910            }
     10911            $cursor_id = 0;
     10912            if (is_array($checkpoint) && isset($checkpoint['cursor'])) {
     10913                $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']);
     10914            } elseif (is_numeric($checkpoint)) {
     10915                $cursor_id = absint($checkpoint);
     10916            }
     10917            $limit = max(1, min(100, absint($limit)));
     10918            $posts_table_name = $wpdb->posts;
     10919            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     10920                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     10921            }
     10922            $posts_table = '`' . $posts_table_name . '`';
     10923// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     10924            $rows = statpl_db_get_results(statpl_db_prepare(
     10925                'SELECT MAX(s.page_id) AS page_id, s.date, SUM(s.count) AS count, s.id
     10926                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s
     10927                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.id AND p.post_type = %s
     10928                 WHERE s.type = %s AND s.page_id <= %d AND s.date <= %s
     10929                 GROUP BY s.date, s.id
     10930                 HAVING MAX(s.page_id) > %d
     10931                 ORDER BY MAX(s.page_id) ASC
     10932                 LIMIT %d',
     10933                'product', 'product', $max_id, $through_date, $cursor_id, $limit + 1
     10934            ), ARRAY_A);
     10935            if (!empty($wpdb->last_error) || !is_array($rows)) {
     10936                throw new RuntimeException('WP Statistics batch query failed.');
     10937            }
     10938            $has_more = count($rows) > $limit;
     10939            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     10940            $next = $cursor_id;
     10941            if ($rows) { $last = end($rows); $next = absint($last['page_id']); }
     10942            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     10943        }
     10944
     10945        public function normalize_record($origin_key, $record) {
     10946            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; }
     10947            $record_id = isset($record['page_id']) ? absint($record['page_id']) : 0;
     10948            $product_id = isset($record['id']) ? absint($record['id']) : 0;
     10949            $views_raw = $record['count'] ?? null;
     10950            $date = isset($record['date']) ? (string) $record['date'] : '';
     10951            if (!$record_id || !$product_id || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; }
     10952            $parsed = DateTime::createFromFormat('!Y-m-d', $date);
     10953            $errors = DateTime::getLastErrors();
     10954            if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; }
     10955            $views = (int) $views_raw;
     10956            $period_start = $date . ' 00:00:00';
     10957            $period_end = $date . ' 23:59:59';
     10958            return [
     10959                'product_id' => $product_id,
     10960                'source_record_key' => 'wp_statistics_product_' . $product_id . '_' . $date,
     10961                'views' => $views,
     10962                'record_type' => 'aggregate',
     10963                'period_start' => $period_start,
     10964                'period_end' => $period_end,
     10965                'cursor' => $record_id,
     10966            ];
     10967        }
     10968    }
     10969}
     10970
     10971/**
     10972 * Post Views Counter adapter.
     10973 *
     10974 * Reads only type=0 daily rows from wp_post_views. Week, month, year and total
     10975 * rows are deliberately excluded because they are overlapping rollups of the
     10976 * same views. Past-day rows are used so the source counts are stable while the
     10977 * importer runs; today's row is still mutable and is left to live tracking.
     10978 */
     10979if (!class_exists('STATPL_Post_Views_Counter_Historical_Adapter')) {
     10980    final class STATPL_Post_Views_Counter_Historical_Adapter implements STATPL_Historical_View_Adapter {
     10981        const ADAPTER_KEY = 'post_views_counter';
     10982        const SOURCE_KEY = 'post_views_counter';
     10983        const ADAPTER_VERSION = '1.0.0';
     10984        const ORIGIN_KEY = 'daily_product_views';
     10985
     10986        public function get_adapter_key() { return self::ADAPTER_KEY; }
     10987        public function get_source_key() { return self::SOURCE_KEY; }
     10988        public function get_display_name() { return 'Post Views Counter'; }
     10989        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     10990
     10991        private function table_name() {
     10992            global $wpdb;
     10993            return $wpdb->prefix . 'post_views';
     10994        }
     10995
     10996        private function quoted_table_name() {
     10997            $table = $this->table_name();
     10998            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     10999                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     11000            }
     11001            return '`' . $table . '`';
     11002        }
     11003
     11004        private function inspect_table() {
     11005            global $wpdb;
     11006            $table = $this->table_name();
     11007            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     11008            if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11009            if ($exists !== $table) { return ['status' => 'missing_table']; }
     11010// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11011            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A);
     11012            if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11013            $names = [];
     11014            foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = (string) $column['Field']; } }
     11015            if (array_diff(['id', 'type', 'period', 'count'], $names)) { return ['status' => 'incompatible_schema']; }
     11016            return ['status' => 'available'];
     11017        }
     11018
     11019        public function should_register_adapter() {
     11020            global $wpdb;
     11021            $inspection = $this->inspect_table();
     11022            if ($inspection['status'] !== 'missing_table') { return true; }
     11023            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     11024            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     11025                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     11026            }
     11027            $registry_table = '`' . $registry_name . '`';
     11028            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     11029            if (!empty($wpdb->last_error)) { return true; }
     11030            if ($registry_exists !== $registry_name) { return false; }
     11031            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     11032// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11033            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     11034            return !empty($wpdb->last_error) || !empty($source_id);
     11035        }
     11036
     11037        public function discover_origins() {
     11038            $inspection = $this->inspect_table();
     11039            if ($inspection['status'] === 'missing_table') { return []; }
     11040            return [[
     11041                'origin_key' => self::ORIGIN_KEY,
     11042                'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'immutable_past_days' => true],
     11043                'detection_status' => $inspection['status'],
     11044            ]];
     11045        }
     11046
     11047        public function get_origin_snapshot($origin_key) {
     11048            global $wpdb;
     11049            if ($origin_key !== self::ORIGIN_KEY) { return []; }
     11050            if ($this->inspect_table()['status'] !== 'available') {
     11051                throw new RuntimeException('Post Views Counter table is unavailable or incompatible.');
     11052            }
     11053            $through_date = current_datetime()->modify('-1 day')->format('Ymd');
     11054            $posts_table_name = $wpdb->posts;
     11055            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     11056                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     11057            }
     11058            $posts_table = '`' . $posts_table_name . '`';
     11059            // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The query has four fixed value placeholders and receives four values; the two table identifiers are independently validated SQL identifiers.
     11060// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11061            $max_period = statpl_db_get_var(statpl_db_prepare(
     11062                'SELECT MAX(v.period)
     11063                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS v
     11064                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = v.id AND p.post_type = %s
     11065                 WHERE v.type = %d AND v.period REGEXP %s AND v.period <= %s',
     11066                'product', 0, '^[0-9]{8}$', $through_date
     11067            ));
     11068            if (!empty($wpdb->last_error)) {
     11069                throw new RuntimeException('Post Views Counter snapshot query failed.');
     11070            }
     11071            if (!is_string($max_period) || !preg_match('/^[0-9]{8}$/D', $max_period)) { $max_period = ''; }
     11072            return [
     11073                'boundary' => ['max_period' => $max_period],
     11074                'initial_checkpoint' => null,
     11075                'captured_at' => current_time('mysql'),
     11076            ];
     11077        }
     11078
     11079        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     11080            global $wpdb;
     11081            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     11082                throw new RuntimeException('Invalid Post Views Counter origin or snapshot.');
     11083            }
     11084            if ($this->inspect_table()['status'] !== 'available') {
     11085                throw new RuntimeException('Post Views Counter table became unavailable or incompatible.');
     11086            }
     11087            $max_period = isset($snapshot['boundary']['max_period']) ? (string) $snapshot['boundary']['max_period'] : '';
     11088            if ($max_period !== '' && !preg_match('/^[0-9]{8}$/D', $max_period)) { throw new RuntimeException('Invalid Post Views Counter snapshot boundary.'); }
     11089            $cursor_period = '';
     11090            $cursor_id = 0;
     11091            if (is_array($checkpoint) && isset($checkpoint['cursor']) && is_array($checkpoint['cursor'])) {
     11092                $cursor_period = isset($checkpoint['cursor']['period']) ? (string) $checkpoint['cursor']['period'] : '';
     11093                $cursor_id = isset($checkpoint['cursor']['id']) ? absint($checkpoint['cursor']['id']) : 0;
     11094            }
     11095            $limit = max(1, min(100, absint($limit)));
     11096            $posts_table_name = $wpdb->posts;
     11097            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     11098                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     11099            }
     11100            $posts_table = '`' . $posts_table_name . '`';
     11101            if ($max_period === '') { $rows = []; }
     11102            else {
     11103                $cursor_sql = '';
     11104                $args = ['product', 0, '^[0-9]{8}$', $max_period];
     11105                if ($cursor_period !== '') {
     11106                    if (!preg_match('/^[0-9]{8}$/D', $cursor_period)) { throw new RuntimeException('Invalid Post Views Counter cursor.'); }
     11107                    $cursor_sql = ' AND (v.period > %s OR (v.period = %s AND v.id > %d))';
     11108                    $args[] = $cursor_period;
     11109                    $args[] = $cursor_period;
     11110                    $args[] = $cursor_id;
     11111                }
     11112                $batch_sql = 'SELECT v.id, v.period, v.count
     11113                     FROM ' . $this->quoted_table_name() . ' AS v
     11114                     INNER JOIN ' . $posts_table . ' AS p ON p.ID = v.id AND p.post_type = %s
     11115                     WHERE v.type = %d AND v.period REGEXP %s AND v.period <= %s' . $cursor_sql . '
     11116                     ORDER BY v.period ASC, v.id ASC
     11117                     LIMIT %d';
     11118                if ($cursor_period !== '') {
     11119// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11120                    $rows = statpl_db_get_results(statpl_db_prepare(
     11121                        /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql,
     11122                        'product', 0, '^[0-9]{8}$', $max_period,
     11123                        $cursor_period, $cursor_period, $cursor_id, $limit + 1
     11124                    ), ARRAY_A);
     11125                } else {
     11126// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11127                    $rows = statpl_db_get_results(statpl_db_prepare(
     11128                        /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql,
     11129                        'product', 0, '^[0-9]{8}$', $max_period, $limit + 1
     11130                    ), ARRAY_A);
     11131                }
     11132            }
     11133            if (!empty($wpdb->last_error) || !is_array($rows)) {
     11134                throw new RuntimeException('Post Views Counter batch query failed.');
     11135            }
     11136            $has_more = count($rows) > $limit;
     11137            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     11138            $next = ($cursor_period !== '') ? ['period' => $cursor_period, 'id' => $cursor_id] : null;
     11139            if ($rows) { $last = end($rows); $next = ['period' => (string) $last['period'], 'id' => absint($last['id'])]; }
     11140            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     11141        }
     11142
     11143        public function normalize_record($origin_key, $record) {
     11144            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; }
     11145            $product_id = isset($record['id']) ? absint($record['id']) : 0;
     11146            $period = isset($record['period']) ? (string) $record['period'] : '';
     11147            $views_raw = $record['count'] ?? null;
     11148            if (!$product_id || !preg_match('/^[0-9]{8}$/D', $period) || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; }
     11149            $parsed = DateTime::createFromFormat('!Ymd', $period);
     11150            $errors = DateTime::getLastErrors();
     11151            if (!$parsed || $parsed->format('Ymd') !== $period || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; }
     11152            $date = $parsed->format('Y-m-d');
     11153            $views = (int) $views_raw;
     11154            $period_start = $date . ' 00:00:00';
     11155            $period_end = $date . ' 23:59:59';
     11156            return [
     11157                'product_id' => $product_id,
     11158                'source_record_key' => 'post_views_counter_day_' . $period . '_' . $product_id,
     11159                'views' => $views,
     11160                'record_type' => 'aggregate',
     11161                'period_start' => $period_start,
     11162                'period_end' => $period_end,
     11163                'cursor' => ['period' => $period, 'id' => $product_id],
     11164            ];
     11165        }
     11166    }
     11167}
     11168
     11169add_filter('statpl_historical_view_adapters', static function ($adapters) {
     11170    if (!is_array($adapters)) { return $adapters; }
     11171    $wp_statistics = new STATPL_WP_Statistics_Historical_Adapter();
     11172    if ($wp_statistics->should_register_adapter()) { $adapters[] = $wp_statistics; }
     11173    $post_views_counter = new STATPL_Post_Views_Counter_Historical_Adapter();
     11174    if ($post_views_counter->should_register_adapter()) { $adapters[] = $post_views_counter; }
     11175    return $adapters;
     11176}, 31);
     11177
     11178/**
     11179 * Statify adapter.
     11180 *
     11181 * Statify stores one immutable row per page view, but its schema contains only
     11182 * a calendar date and target URL (not a timestamp or WordPress post ID). The
     11183 * adapter resolves same-site targets to published WooCommerce products and
     11184 * explicitly skips non-product rows. It does not issue cross-source fingerprints:
     11185 * a date-only row cannot be safely matched one-to-one against another provider's
     11186 * event stream without risking suppression of legitimate repeat views.
     11187 */
     11188if (!class_exists('STATPL_Statify_Historical_Adapter')) {
     11189    final class STATPL_Statify_Historical_Adapter implements STATPL_Historical_View_Adapter {
     11190        const ADAPTER_KEY = 'statify';
     11191        const SOURCE_KEY = 'statify';
     11192        const ADAPTER_VERSION = '1.0.0';
     11193        const ORIGIN_KEY = 'page_view_events';
     11194
     11195        public function get_adapter_key() { return self::ADAPTER_KEY; }
     11196        public function get_source_key() { return self::SOURCE_KEY; }
     11197        public function get_display_name() { return 'Statify'; }
     11198        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     11199
     11200        private function table_name() {
     11201            global $wpdb;
     11202            return $wpdb->prefix . 'statify';
     11203        }
     11204
     11205        private function quoted_table_name() {
     11206            $table = $this->table_name();
     11207            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     11208                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     11209            }
     11210            return '`' . $table . '`';
     11211        }
     11212
     11213        private function inspect_table() {
     11214            global $wpdb;
     11215            $table = $this->table_name();
     11216            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     11217            if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11218            if ($exists !== $table) { return ['status' => 'missing_table']; }
     11219// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11220            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A);
     11221            if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11222            $names = [];
     11223            foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = (string) $column['Field']; } }
     11224            if (array_diff(['id', 'created', 'referrer', 'target'], $names)) { return ['status' => 'incompatible_schema']; }
     11225            return ['status' => 'available'];
     11226        }
     11227
     11228        public function should_register_adapter() {
     11229            global $wpdb;
     11230            $inspection = $this->inspect_table();
     11231            if ($inspection['status'] !== 'missing_table') { return true; }
     11232            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     11233            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     11234                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     11235            }
     11236            $registry_table = '`' . $registry_name . '`';
     11237            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     11238            if (!empty($wpdb->last_error)) { return true; }
     11239            if ($registry_exists !== $registry_name) { return false; }
     11240            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     11241// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11242            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     11243            return !empty($wpdb->last_error) || !empty($source_id);
     11244        }
     11245
     11246        public function discover_origins() {
     11247            $inspection = $this->inspect_table();
     11248            if ($inspection['status'] === 'missing_table') { return []; }
     11249            return [[
     11250                'origin_key' => self::ORIGIN_KEY,
     11251                'capabilities' => ['product_views' => true, 'granularity' => 'event_date_only', 'target_requires_product_resolution' => true],
     11252                'detection_status' => $inspection['status'],
     11253            ]];
     11254        }
     11255
     11256        public function get_origin_snapshot($origin_key) {
     11257            global $wpdb;
     11258            if ($origin_key !== self::ORIGIN_KEY) { return []; }
     11259            if ($this->inspect_table()['status'] !== 'available') {
     11260                throw new RuntimeException('Statify table is unavailable or incompatible.');
     11261            }
     11262// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11263            $max_id = statpl_db_get_var('SELECT MAX(id) FROM ' . $this->quoted_table_name());
     11264            if (!empty($wpdb->last_error)) {
     11265                throw new RuntimeException('Statify snapshot query failed.');
     11266            }
     11267            return [
     11268                'boundary' => ['max_id' => max(0, (int) $max_id)],
     11269                'initial_checkpoint' => null,
     11270                'captured_at' => current_time('mysql'),
     11271            ];
     11272        }
     11273
     11274        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     11275            global $wpdb;
     11276            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     11277                throw new RuntimeException('Invalid Statify origin or snapshot.');
     11278            }
     11279            if ($this->inspect_table()['status'] !== 'available') {
     11280                throw new RuntimeException('Statify table became unavailable or incompatible.');
     11281            }
     11282            $max_id = isset($snapshot['boundary']['max_id']) ? absint($snapshot['boundary']['max_id']) : -1;
     11283            if ($max_id < 0) { throw new RuntimeException('Invalid Statify snapshot boundary.'); }
     11284            $cursor_id = 0;
     11285            if (is_array($checkpoint) && array_key_exists('cursor', $checkpoint)) {
     11286                $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']);
     11287            } elseif (is_numeric($checkpoint)) {
     11288                $cursor_id = absint($checkpoint);
     11289            }
     11290            $limit = max(1, min(100, absint($limit)));
     11291// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11292            $rows = statpl_db_get_results(statpl_db_prepare(
     11293                'SELECT id, created, referrer, target
     11294                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . '
     11295                 WHERE id > %d AND id <= %d
     11296                 ORDER BY id ASC
     11297                 LIMIT %d',
     11298                $cursor_id,
     11299                $max_id,
     11300                $limit + 1
     11301            ), ARRAY_A);
     11302            if (!empty($wpdb->last_error) || !is_array($rows)) {
     11303                throw new RuntimeException('Statify batch query failed.');
     11304            }
     11305            $has_more = count($rows) > $limit;
     11306            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     11307            $next = $cursor_id;
     11308            if ($rows) { $last = end($rows); $next = absint($last['id']); }
     11309            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     11310        }
     11311
     11312        private function resolve_product_id($target) {
     11313            if (!is_string($target) || trim($target) === '' || !function_exists('url_to_postid')) { return 0; }
     11314            $target = trim($target);
     11315            if (preg_match('#^https?://#i', $target)) {
     11316                $url = $target;
     11317            } elseif (strpos($target, '//') === 0) {
     11318                return 0;
     11319            } else {
     11320                $url = home_url('/' . ltrim($target, '/'));
     11321            }
     11322            $target_host = strtolower((string) wp_parse_url($url, PHP_URL_HOST));
     11323            $home_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
     11324            if ($target_host === '' || $home_host === '' || $target_host !== $home_host) { return 0; }
     11325            $product_id = absint(url_to_postid($url));
     11326            if (!$product_id || get_post_type($product_id) !== 'product') { return 0; }
     11327            return $product_id;
     11328        }
     11329
     11330        public function normalize_record($origin_key, $record) {
     11331            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; }
     11332            $record_id = isset($record['id']) ? absint($record['id']) : 0;
     11333            $record_key = $record_id ? 'statify_view_' . $record_id : '';
     11334            $cursor = $record_id;
     11335            if (!$record_id) { return null; }
     11336            $date = isset($record['created']) ? (string) $record['created'] : '';
     11337            $parsed = DateTime::createFromFormat('!Y-m-d', $date);
     11338            $errors = DateTime::getLastErrors();
     11339            if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) {
     11340                return null;
     11341            }
     11342            $product_id = $this->resolve_product_id(isset($record['target']) ? (string) $record['target'] : '');
     11343            if (!$product_id) {
     11344                return ['skip' => true, 'source_record_key' => $record_key, 'cursor' => $cursor];
     11345            }
     11346            $referrer = isset($record['referrer']) ? (string) $record['referrer'] : '';
     11347            return [
     11348                'product_id' => $product_id,
     11349                'source_record_key' => $record_key,
     11350                'views' => 1,
     11351                'record_type' => 'event',
     11352                'period_start' => $date . ' 00:00:00',
     11353                'period_end' => $date . ' 23:59:59',
     11354                'cursor' => $cursor,
     11355                'source_referrer_hash' => hash('sha256', $referrer),
     11356            ];
     11357        }
     11358
     11359        public function get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event) {
     11360            return [
     11361                'source' => 'statify',
     11362                'date_precision' => 'calendar_day_only',
     11363                'referrer_sha256' => isset($normalized_record['source_referrer_hash']) ? $normalized_record['source_referrer_hash'] : null,
     11364                'overlap_assessment' => 'possible_but_not_proven',
     11365                'automatic_suppression' => false,
     11366                'reason' => 'Statify has no per-view timestamp or stable cross-provider event identifier; a one-to-one match would risk suppressing legitimate repeat views.',
     11367            ];
     11368        }
     11369    }
     11370}
     11371
     11372add_filter('statpl_historical_view_adapters', static function ($adapters) {
     11373    if (!is_array($adapters)) { return $adapters; }
     11374    $adapter = new STATPL_Statify_Historical_Adapter();
     11375    if ($adapter->should_register_adapter()) { $adapters[] = $adapter; }
     11376    return $adapters;
     11377}, 32);
     11378
     11379/**
     11380 * WP Popular Posts adapter.
     11381 *
     11382 * Imports the per-post/per-day summary table only. The all-time data table is
     11383 * deliberately excluded because it overlaps the daily summary and carries no
     11384 * date range. WPP's configurable log retention determines how much daily
     11385 * history remains available at the time Statora captures its snapshot.
     11386 */
     11387if (!class_exists('STATPL_WP_Popular_Posts_Historical_Adapter')) {
     11388    final class STATPL_WP_Popular_Posts_Historical_Adapter implements STATPL_Historical_View_Adapter {
     11389        const ADAPTER_KEY = 'wp_popular_posts';
     11390        const SOURCE_KEY = 'wp_popular_posts';
     11391        const ADAPTER_VERSION = '1.0.0';
     11392        const ORIGIN_KEY = 'product_daily_summary';
     11393
     11394        public function get_adapter_key() { return self::ADAPTER_KEY; }
     11395        public function get_source_key() { return self::SOURCE_KEY; }
     11396        public function get_display_name() { return 'WP Popular Posts'; }
     11397        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     11398
     11399        private function table_name() {
     11400            global $wpdb;
     11401            return $wpdb->prefix . 'popularpostssummary';
     11402        }
     11403
     11404        private function quoted_table_name() {
     11405            $table = $this->table_name();
     11406            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     11407                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     11408            }
     11409            return '`' . $table . '`';
     11410        }
     11411
     11412        private function inspect_table() {
     11413            global $wpdb;
     11414            $table = $this->table_name();
     11415            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     11416            if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11417            if ($exists !== $table) { return ['status' => 'missing_table']; }
     11418// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11419            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A);
     11420            if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11421            $names = [];
     11422            foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } }
     11423            if (array_diff(['id', 'postid', 'pageviews', 'view_date', 'last_viewed'], $names)) { return ['status' => 'incompatible_schema']; }
     11424            return ['status' => 'available'];
     11425        }
     11426
     11427        public function should_register_adapter() {
     11428            global $wpdb;
     11429            $inspection = $this->inspect_table();
     11430            if ($inspection['status'] !== 'missing_table') { return true; }
     11431            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     11432            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     11433                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     11434            }
     11435            $registry_table = '`' . $registry_name . '`';
     11436            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     11437            if (!empty($wpdb->last_error)) { return true; }
     11438            if ($registry_exists !== $registry_name) { return false; }
     11439            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     11440// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11441            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     11442            return !empty($wpdb->last_error) || !empty($source_id);
     11443        }
     11444
     11445        public function discover_origins() {
     11446            $inspection = $this->inspect_table();
     11447            if ($inspection['status'] === 'missing_table') { return []; }
     11448            return [[
     11449                'origin_key' => self::ORIGIN_KEY,
     11450                'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'immutable_past_days' => true],
     11451                'detection_status' => $inspection['status'],
     11452            ]];
     11453        }
     11454
     11455        public function get_origin_snapshot($origin_key) {
     11456            global $wpdb;
     11457            if ($origin_key !== self::ORIGIN_KEY) { return []; }
     11458            if ($this->inspect_table()['status'] !== 'available') {
     11459                throw new RuntimeException('WP Popular Posts summary table is unavailable or incompatible.');
     11460            }
     11461            $through_date = current_datetime()->modify('-1 day')->format('Y-m-d');
     11462            $posts_table_name = $wpdb->posts;
     11463            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     11464                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     11465            }
     11466            $posts_table = '`' . $posts_table_name . '`';
     11467// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11468            $max_id = statpl_db_get_var(statpl_db_prepare(
     11469                'SELECT MAX(s.ID)
     11470                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s
     11471                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.postid AND p.post_type = %s
     11472                 WHERE s.view_date <= %s',
     11473                'product', $through_date
     11474            ));
     11475            if (!empty($wpdb->last_error)) {
     11476                throw new RuntimeException('WP Popular Posts snapshot query failed.');
     11477            }
     11478            return [
     11479                'boundary' => ['max_id' => max(0, (int) $max_id), 'through_date' => $through_date],
     11480                'initial_checkpoint' => null,
     11481                'captured_at' => current_time('mysql'),
     11482            ];
     11483        }
     11484
     11485        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     11486            global $wpdb;
     11487            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     11488                throw new RuntimeException('Invalid WP Popular Posts origin or snapshot.');
     11489            }
     11490            if ($this->inspect_table()['status'] !== 'available') {
     11491                throw new RuntimeException('WP Popular Posts summary table became unavailable or incompatible.');
     11492            }
     11493            $boundary = $snapshot['boundary'];
     11494            $max_id = isset($boundary['max_id']) ? absint($boundary['max_id']) : -1;
     11495            $through_date = isset($boundary['through_date']) ? (string) $boundary['through_date'] : '';
     11496            $parsed = DateTime::createFromFormat('!Y-m-d', $through_date);
     11497            if ($max_id < 0 || !$parsed || $parsed->format('Y-m-d') !== $through_date) {
     11498                throw new RuntimeException('Invalid WP Popular Posts snapshot boundary.');
     11499            }
     11500            $cursor_id = 0;
     11501            if (is_array($checkpoint) && isset($checkpoint['cursor'])) {
     11502                $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']);
     11503            } elseif (is_numeric($checkpoint)) { $cursor_id = absint($checkpoint); }
     11504            $limit = max(1, min(100, absint($limit)));
     11505            $posts_table_name = $wpdb->posts;
     11506            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     11507                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     11508            }
     11509            $posts_table = '`' . $posts_table_name . '`';
     11510// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11511            $rows = statpl_db_get_results(statpl_db_prepare(
     11512                'SELECT s.ID AS id, s.postid, s.pageviews, s.view_date
     11513                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s
     11514                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.postid AND p.post_type = %s
     11515                 WHERE s.ID > %d AND s.ID <= %d AND s.view_date <= %s
     11516                 ORDER BY s.ID ASC
     11517                 LIMIT %d',
     11518                'product', $cursor_id, $max_id, $through_date, $limit + 1
     11519            ), ARRAY_A);
     11520            if (!empty($wpdb->last_error) || !is_array($rows)) {
     11521                throw new RuntimeException('WP Popular Posts batch query failed.');
     11522            }
     11523            $has_more = count($rows) > $limit;
     11524            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     11525            $next = $cursor_id;
     11526            if ($rows) { $last = end($rows); $next = absint($last['id']); }
     11527            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     11528        }
     11529
     11530        public function normalize_record($origin_key, $record) {
     11531            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; }
     11532            $record_id = isset($record['id']) ? absint($record['id']) : 0;
     11533            $product_id = isset($record['postid']) ? absint($record['postid']) : 0;
     11534            $views_raw = $record['pageviews'] ?? null;
     11535            $date = isset($record['view_date']) ? (string) $record['view_date'] : '';
     11536            if (!$record_id || !$product_id || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; }
     11537            $parsed = DateTime::createFromFormat('!Y-m-d', $date);
     11538            $errors = DateTime::getLastErrors();
     11539            if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; }
     11540            $views = (int) $views_raw;
     11541            $period_start = $date . ' 00:00:00';
     11542            $period_end = $date . ' 23:59:59';
     11543            return [
     11544                'product_id' => $product_id,
     11545                'source_record_key' => 'wp_popular_posts_' . $product_id . '_' . $date,
     11546                'views' => $views,
     11547                'record_type' => 'aggregate',
     11548                'period_start' => $period_start,
     11549                'period_end' => $period_end,
     11550                'cursor' => $record_id,
     11551            ];
     11552        }
     11553    }
     11554}
     11555
     11556add_filter('statpl_historical_view_adapters', static function ($adapters) {
     11557    if (!is_array($adapters)) { return $adapters; }
     11558    $adapter = new STATPL_WP_Popular_Posts_Historical_Adapter();
     11559    if ($adapter->should_register_adapter()) { $adapters[] = $adapter; }
     11560    return $adapters;
     11561}, 33);
     11562
     11563/**
     11564 * WebberZone Top 10 adapter.
     11565 *
     11566 * Imports only the per-post hourly rows from top_ten_daily, grouped into local
     11567 * calendar-day totals. The all-time table, visits funnel and visits log are not
     11568 * imported in parallel: the funnel/log feed the same daily and lifetime counts.
     11569 * A two-day safety lag allows Top 10's background aggregation cron to drain
     11570 * completed days before the snapshot is taken.
     11571 */
     11572if (!class_exists('STATPL_Top_Ten_Historical_Adapter')) {
     11573    final class STATPL_Top_Ten_Historical_Adapter implements STATPL_Historical_View_Adapter {
     11574        const ADAPTER_KEY = 'webberzone_top_10';
     11575        const SOURCE_KEY = 'webberzone_top_10';
     11576        const ADAPTER_VERSION = '1.0.0';
     11577        const ORIGIN_KEY = 'product_daily_counts';
     11578
     11579        public function get_adapter_key() { return self::ADAPTER_KEY; }
     11580        public function get_source_key() { return self::SOURCE_KEY; }
     11581        public function get_display_name() { return 'WebberZone Top 10'; }
     11582        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     11583
     11584        private function table_name() {
     11585            global $wpdb;
     11586            return $wpdb->base_prefix . 'top_ten_daily';
     11587        }
     11588
     11589        private function quoted_table_name() {
     11590            $table = $this->table_name();
     11591            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     11592                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     11593            }
     11594            return '`' . $table . '`';
     11595        }
     11596
     11597        private function inspect_table() {
     11598            global $wpdb;
     11599            $table = $this->table_name();
     11600            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     11601            if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11602            if ($exists !== $table) { return ['status' => 'missing_table']; }
     11603// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11604            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A);
     11605            if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11606            $names = [];
     11607            foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } }
     11608            if (array_diff(['postnumber', 'cntaccess', 'dp_date', 'blog_id'], $names)) { return ['status' => 'incompatible_schema']; }
     11609            return ['status' => 'available', 'has_feed_column' => in_array('feedcnt', $names, true)];
     11610        }
     11611
     11612        public function should_register_adapter() {
     11613            global $wpdb;
     11614            $inspection = $this->inspect_table();
     11615            if ($inspection['status'] !== 'missing_table') { return true; }
     11616            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     11617            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     11618                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     11619            }
     11620            $registry_table = '`' . $registry_name . '`';
     11621            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     11622            if (!empty($wpdb->last_error)) { return true; }
     11623            if ($registry_exists !== $registry_name) { return false; }
     11624            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     11625// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11626            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     11627            return !empty($wpdb->last_error) || !empty($source_id);
     11628        }
     11629
     11630        public function discover_origins() {
     11631            $inspection = $this->inspect_table();
     11632            if ($inspection['status'] === 'missing_table') { return []; }
     11633            return [[
     11634                'origin_key' => self::ORIGIN_KEY,
     11635                'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'source_storage_granularity' => 'hourly', 'immutable_past_days' => true],
     11636                'detection_status' => $inspection['status'],
     11637            ]];
     11638        }
     11639
     11640        public function get_origin_snapshot($origin_key) {
     11641            if ($origin_key !== self::ORIGIN_KEY) { return []; }
     11642            if ($this->inspect_table()['status'] !== 'available') {
     11643                throw new RuntimeException('Top 10 daily table is unavailable or incompatible.');
     11644            }
     11645            $through_date = current_datetime()->modify('-2 days')->format('Y-m-d');
     11646            return [
     11647                'boundary' => ['through_date' => $through_date, 'blog_id' => absint(get_current_blog_id())],
     11648                'initial_checkpoint' => null,
     11649                'captured_at' => current_time('mysql'),
     11650            ];
     11651        }
     11652
     11653        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     11654            global $wpdb;
     11655            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     11656                throw new RuntimeException('Invalid Top 10 origin or snapshot.');
     11657            }
     11658            if ($this->inspect_table()['status'] !== 'available') {
     11659                throw new RuntimeException('Top 10 daily table became unavailable or incompatible.');
     11660            }
     11661            $through_date = isset($snapshot['boundary']['through_date']) ? (string) $snapshot['boundary']['through_date'] : '';
     11662            $parsed = DateTime::createFromFormat('!Y-m-d', $through_date);
     11663            $blog_id = isset($snapshot['boundary']['blog_id']) ? absint($snapshot['boundary']['blog_id']) : 0;
     11664            if (!$parsed || $parsed->format('Y-m-d') !== $through_date || !$blog_id) {
     11665                throw new RuntimeException('Invalid Top 10 snapshot boundary.');
     11666            }
     11667            $cursor_date = '';
     11668            $cursor_product = 0;
     11669            if (is_array($checkpoint) && isset($checkpoint['cursor']) && is_array($checkpoint['cursor'])) {
     11670                $cursor_date = isset($checkpoint['cursor']['date']) ? (string) $checkpoint['cursor']['date'] : '';
     11671                $cursor_product = isset($checkpoint['cursor']['product_id']) ? absint($checkpoint['cursor']['product_id']) : 0;
     11672            }
     11673            if ($cursor_date !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/D', $cursor_date) || !DateTime::createFromFormat('!Y-m-d', $cursor_date))) {
     11674                throw new RuntimeException('Invalid Top 10 cursor date.');
     11675            }
     11676            $limit = max(1, min(100, absint($limit)));
     11677            $posts_table_name = $wpdb->posts;
     11678            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     11679                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     11680            }
     11681            $posts_table = '`' . $posts_table_name . '`';
     11682            $inspection = $this->inspect_table();
     11683            $view_sum_sql = !empty($inspection['has_feed_column'])
     11684                ? 'SUM(GREATEST(t.cntaccess - t.feedcnt, 0))'
     11685                : 'SUM(t.cntaccess)';
     11686            if (!in_array($view_sum_sql, ['SUM(GREATEST(t.cntaccess - t.feedcnt, 0))', 'SUM(t.cntaccess)'], true)) {
     11687                throw new RuntimeException('Unsupported Top 10 view aggregation expression.');
     11688            }
     11689            $cursor_sql = '';
     11690            $args = ['product', $blog_id, $through_date];
     11691            if ($cursor_date !== '') {
     11692                $cursor_sql = ' HAVING (view_date > %s OR (view_date = %s AND product_id > %d))';
     11693                $args[] = $cursor_date;
     11694                $args[] = $cursor_date;
     11695                $args[] = $cursor_product;
     11696            }
     11697            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $view_sum_sql is selected from two fixed aggregate expressions and checked against the same strict allowlist; all runtime values remain prepared placeholders.
     11698            $batch_sql = 'SELECT DATE(t.dp_date) AS view_date, t.postnumber AS product_id, ' . $view_sum_sql . ' AS views
     11699                 FROM ' . $this->quoted_table_name() . ' AS t
     11700                 INNER JOIN ' . $posts_table . ' AS p ON p.ID = t.postnumber AND p.post_type = %s
     11701                 WHERE t.blog_id = %d AND t.dp_date < DATE_ADD(%s, INTERVAL 1 DAY)
     11702                 GROUP BY DATE(t.dp_date), t.postnumber' . $cursor_sql . '
     11703                 ORDER BY view_date ASC, product_id ASC
     11704                 LIMIT %d';
     11705            if ($cursor_date !== '') {
     11706// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11707                $rows = statpl_db_get_results(statpl_db_prepare(
     11708                    /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql,
     11709                    'product', $blog_id, $through_date,
     11710                    $cursor_date, $cursor_date, $cursor_product, $limit + 1
     11711                ), ARRAY_A);
     11712            } else {
     11713// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11714                $rows = statpl_db_get_results(statpl_db_prepare(
     11715                    /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql,
     11716                    'product', $blog_id, $through_date, $limit + 1
     11717                ), ARRAY_A);
     11718            }
     11719            if (!empty($wpdb->last_error) || !is_array($rows)) {
     11720                throw new RuntimeException('Top 10 batch query failed.');
     11721            }
     11722            $has_more = count($rows) > $limit;
     11723            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     11724            $next = ($cursor_date !== '') ? ['date' => $cursor_date, 'product_id' => $cursor_product] : null;
     11725            if ($rows) { $last = end($rows); $next = ['date' => (string) $last['view_date'], 'product_id' => absint($last['product_id'])]; }
     11726            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     11727        }
     11728
     11729        public function normalize_record($origin_key, $record) {
     11730            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; }
     11731            $product_id = isset($record['product_id']) ? absint($record['product_id']) : 0;
     11732            $date = isset($record['view_date']) ? (string) $record['view_date'] : '';
     11733            $views_raw = $record['views'] ?? null;
     11734            if (!$product_id || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; }
     11735            $parsed = DateTime::createFromFormat('!Y-m-d', $date);
     11736            $errors = DateTime::getLastErrors();
     11737            if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; }
     11738            $views = (int) $views_raw;
     11739            $period_start = $date . ' 00:00:00';
     11740            $period_end = $date . ' 23:59:59';
     11741            return [
     11742                'product_id' => $product_id,
     11743                'source_record_key' => 'top_10_' . $product_id . '_' . $date,
     11744                'views' => $views,
     11745                'record_type' => 'aggregate',
     11746                'period_start' => $period_start,
     11747                'period_end' => $period_end,
     11748                'cursor' => ['date' => $date, 'product_id' => $product_id],
     11749            ];
     11750        }
     11751    }
     11752}
     11753
     11754add_filter('statpl_historical_view_adapters', static function ($adapters) {
     11755    if (!is_array($adapters)) { return $adapters; }
     11756    $adapter = new STATPL_Top_Ten_Historical_Adapter();
     11757    if ($adapter->should_register_adapter()) { $adapters[] = $adapter; }
     11758    return $adapters;
     11759}, 34);
     11760
     11761/**
     11762 * Koko Analytics adapter.
     11763 *
     11764 * Reads the per-page daily statistics table. Current Koko schemas use path_id
     11765 * and post_id; older schemas used id directly as the WordPress post ID. Rows
     11766 * are grouped by product/date because one product can be represented by more
     11767 * than one canonicalized path. Only completed days are imported.
     11768 */
     11769if (!class_exists('STATPL_Koko_Analytics_Historical_Adapter')) {
     11770    final class STATPL_Koko_Analytics_Historical_Adapter implements STATPL_Historical_View_Adapter {
     11771        const ADAPTER_KEY = 'koko_analytics';
     11772        const SOURCE_KEY = 'koko_analytics';
     11773        const ADAPTER_VERSION = '1.0.0';
     11774        const ORIGIN_KEY = 'product_daily_stats';
     11775
     11776        public function get_adapter_key() { return self::ADAPTER_KEY; }
     11777        public function get_source_key() { return self::SOURCE_KEY; }
     11778        public function get_display_name() { return 'Koko Analytics'; }
     11779        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     11780
     11781        private function table_name() {
     11782            global $wpdb;
     11783            return $wpdb->prefix . 'koko_analytics_post_stats';
     11784        }
     11785
     11786        private function quoted_table_name() {
     11787            $table = $this->table_name();
     11788            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     11789                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     11790            }
     11791            return '`' . $table . '`';
     11792        }
     11793
     11794        private function inspect_table() {
     11795            global $wpdb;
     11796            $table = $this->table_name();
     11797            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     11798            if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11799            if ($exists !== $table) { return ['status' => 'missing_table']; }
     11800// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11801            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A);
     11802            if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11803            $names = [];
     11804            foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } }
     11805            if (!in_array('date', $names, true) || !in_array('pageviews', $names, true)) { return ['status' => 'incompatible_schema']; }
     11806            if (in_array('post_id', $names, true) && in_array('path_id', $names, true)) {
     11807                return ['status' => 'available', 'schema_mode' => 'path_id'];
     11808            }
     11809            if (in_array('id', $names, true)) {
     11810                return ['status' => 'available', 'schema_mode' => 'legacy_post_id'];
     11811            }
     11812            return ['status' => 'incompatible_schema'];
     11813        }
     11814
     11815        public function should_register_adapter() {
     11816            global $wpdb;
     11817            $inspection = $this->inspect_table();
     11818            if ($inspection['status'] !== 'missing_table') { return true; }
     11819            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     11820            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     11821                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     11822            }
     11823            $registry_table = '`' . $registry_name . '`';
     11824            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     11825            if (!empty($wpdb->last_error)) { return true; }
     11826            if ($registry_exists !== $registry_name) { return false; }
     11827            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     11828// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11829            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     11830            return !empty($wpdb->last_error) || !empty($source_id);
     11831        }
     11832
     11833        public function discover_origins() {
     11834            $inspection = $this->inspect_table();
     11835            if ($inspection['status'] === 'missing_table') { return []; }
     11836            return [[
     11837                'origin_key' => self::ORIGIN_KEY,
     11838                'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'schema_mode' => isset($inspection['schema_mode']) ? $inspection['schema_mode'] : 'unknown'],
     11839                'detection_status' => $inspection['status'],
     11840            ]];
     11841        }
     11842
     11843        public function get_origin_snapshot($origin_key) {
     11844            if ($origin_key !== self::ORIGIN_KEY) { return []; }
     11845            $inspection = $this->inspect_table();
     11846            if ($inspection['status'] !== 'available') {
     11847                throw new RuntimeException('Koko Analytics post statistics table is unavailable or incompatible.');
     11848            }
     11849            return [
     11850                'boundary' => [
     11851                    'through_date' => current_datetime()->modify('-1 day')->format('Y-m-d'),
     11852                    'schema_mode' => $inspection['schema_mode'],
     11853                ],
     11854                'initial_checkpoint' => null,
     11855                'captured_at' => current_time('mysql'),
     11856            ];
     11857        }
     11858
     11859        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     11860            global $wpdb;
     11861            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     11862                throw new RuntimeException('Invalid Koko Analytics origin or snapshot.');
     11863            }
     11864            $inspection = $this->inspect_table();
     11865            if ($inspection['status'] !== 'available') {
     11866                throw new RuntimeException('Koko Analytics post statistics table became unavailable or incompatible.');
     11867            }
     11868            $boundary = $snapshot['boundary'];
     11869            $through_date = isset($boundary['through_date']) ? (string) $boundary['through_date'] : '';
     11870            $parsed = DateTime::createFromFormat('!Y-m-d', $through_date);
     11871            $schema_mode = isset($boundary['schema_mode']) ? (string) $boundary['schema_mode'] : '';
     11872            if (!$parsed || $parsed->format('Y-m-d') !== $through_date || $schema_mode !== $inspection['schema_mode']) {
     11873                throw new RuntimeException('Invalid or changed Koko Analytics snapshot boundary.');
     11874            }
     11875            $cursor_date = '';
     11876            $cursor_product = 0;
     11877            if (is_array($checkpoint) && isset($checkpoint['cursor']) && is_array($checkpoint['cursor'])) {
     11878                $cursor_date = isset($checkpoint['cursor']['date']) ? (string) $checkpoint['cursor']['date'] : '';
     11879                $cursor_product = isset($checkpoint['cursor']['product_id']) ? absint($checkpoint['cursor']['product_id']) : 0;
     11880            }
     11881            if ($cursor_date !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/D', $cursor_date) || !DateTime::createFromFormat('!Y-m-d', $cursor_date))) {
     11882                throw new RuntimeException('Invalid Koko Analytics cursor date.');
     11883            }
     11884            $limit = max(1, min(100, absint($limit)));
     11885            $posts_table_name = $wpdb->posts;
     11886            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     11887                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     11888            }
     11889            $posts_table = '`' . $posts_table_name . '`';
     11890            if (!in_array($schema_mode, ['path_id', 'id'], true)) {
     11891                throw new RuntimeException('Unsupported Koko Analytics schema mode.');
     11892            }
     11893            $post_id_column = $schema_mode === 'path_id' ? 's.post_id' : 's.id';
     11894            if (!in_array($post_id_column, ['s.post_id', 's.id'], true)) {
     11895                throw new RuntimeException('Unsupported Koko Analytics product identifier column.');
     11896            }
     11897            $cursor_sql = '';
     11898            $args = ['product', $through_date];
     11899            if ($cursor_date !== '') {
     11900                $cursor_sql = ' HAVING (view_date > %s OR (view_date = %s AND product_id > %d))';
     11901                $args[] = $cursor_date;
     11902                $args[] = $cursor_date;
     11903                $args[] = $cursor_product;
     11904            }
     11905            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $post_id_column is selected from the fixed allowlist s.post_id/s.id after schema-mode validation; runtime values remain prepared placeholders.
     11906            $batch_sql = 'SELECT s.date AS view_date, ' . $post_id_column . ' AS product_id, SUM(s.pageviews) AS views
     11907                 FROM ' . $this->quoted_table_name() . ' AS s
     11908                 INNER JOIN ' . $posts_table . ' AS p ON p.ID = ' . $post_id_column . ' AND p.post_type = %s
     11909                 WHERE s.date <= %s
     11910                 GROUP BY s.date, ' . $post_id_column . $cursor_sql . '
     11911                 ORDER BY view_date ASC, product_id ASC
     11912                 LIMIT %d';
     11913            if ($cursor_date !== '') {
     11914// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11915                $rows = statpl_db_get_results(statpl_db_prepare(
     11916                    /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql,
     11917                    'product', $through_date,
     11918                    $cursor_date, $cursor_date, $cursor_product, $limit + 1
     11919                ), ARRAY_A);
     11920            } else {
     11921// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11922                $rows = statpl_db_get_results(statpl_db_prepare(
     11923                    /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql,
     11924                    'product', $through_date, $limit + 1
     11925                ), ARRAY_A);
     11926            }
     11927            if (!empty($wpdb->last_error) || !is_array($rows)) {
     11928                throw new RuntimeException('Koko Analytics batch query failed.');
     11929            }
     11930            $has_more = count($rows) > $limit;
     11931            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     11932            $next = ($cursor_date !== '') ? ['date' => $cursor_date, 'product_id' => $cursor_product] : null;
     11933            if ($rows) { $last = end($rows); $next = ['date' => (string) $last['view_date'], 'product_id' => absint($last['product_id'])]; }
     11934            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     11935        }
     11936
     11937        public function normalize_record($origin_key, $record) {
     11938            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; }
     11939            $product_id = isset($record['product_id']) ? absint($record['product_id']) : 0;
     11940            $date = isset($record['view_date']) ? (string) $record['view_date'] : '';
     11941            $views_raw = $record['views'] ?? null;
     11942            if (!$product_id || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; }
     11943            $parsed = DateTime::createFromFormat('!Y-m-d', $date);
     11944            $errors = DateTime::getLastErrors();
     11945            if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; }
     11946            $views = (int) $views_raw;
     11947            $period_start = $date . ' 00:00:00';
     11948            $period_end = $date . ' 23:59:59';
     11949            return [
     11950                'product_id' => $product_id,
     11951                'source_record_key' => 'koko_analytics_' . $product_id . '_' . $date,
     11952                'views' => $views,
     11953                'record_type' => 'aggregate',
     11954                'period_start' => $period_start,
     11955                'period_end' => $period_end,
     11956                'cursor' => ['date' => $date, 'product_id' => $product_id],
     11957            ];
     11958        }
     11959    }
     11960}
     11961
     11962add_filter('statpl_historical_view_adapters', static function ($adapters) {
     11963    if (!is_array($adapters)) { return $adapters; }
     11964    $adapter = new STATPL_Koko_Analytics_Historical_Adapter();
     11965    if ($adapter->should_register_adapter()) { $adapters[] = $adapter; }
     11966    return $adapters;
     11967}, 35);
     11968
     11969/**
     11970 * Burst Statistics adapter.
     11971 *
     11972 * Imports raw product pageview rows from burst_statistics. Unlike aggregate
     11973 * providers, Burst stores a WordPress post ID and a Unix timestamp per hit, so
     11974 * product resolution is direct and each source row has a stable ID. Visitor
     11975 * identifiers and session data are intentionally not read or imported.
     11976 */
     11977if (!class_exists('STATPL_Burst_Statistics_Historical_Adapter')) {
     11978    final class STATPL_Burst_Statistics_Historical_Adapter implements STATPL_Historical_View_Adapter {
     11979        const ADAPTER_KEY = 'burst_statistics';
     11980        const SOURCE_KEY = 'burst_statistics';
     11981        const ADAPTER_VERSION = '1.0.0';
     11982        const ORIGIN_KEY = 'product_pageview_events';
     11983
     11984        public function get_adapter_key() { return self::ADAPTER_KEY; }
     11985        public function get_source_key() { return self::SOURCE_KEY; }
     11986        public function get_display_name() { return 'Burst Statistics'; }
     11987        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     11988
     11989        private function table_name() {
     11990            global $wpdb;
     11991            return $wpdb->prefix . 'burst_statistics';
     11992        }
     11993
     11994        private function quoted_table_name() {
     11995            $table = $this->table_name();
     11996            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     11997                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     11998            }
     11999            return '`' . $table . '`';
     12000        }
     12001
     12002        private function inspect_table() {
     12003            global $wpdb;
     12004            $table = $this->table_name();
     12005            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     12006            if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     12007            if ($exists !== $table) { return ['status' => 'missing_table']; }
     12008// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12009            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A);
     12010            if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     12011            $names = [];
     12012            foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } }
     12013            if (array_diff(['id', 'page_id', 'page_type', 'time'], $names)) { return ['status' => 'incompatible_schema']; }
     12014            return ['status' => 'available'];
     12015        }
     12016
     12017        public function should_register_adapter() {
     12018            global $wpdb;
     12019            $inspection = $this->inspect_table();
     12020            if ($inspection['status'] !== 'missing_table') { return true; }
     12021            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     12022            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     12023                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     12024            }
     12025            $registry_table = '`' . $registry_name . '`';
     12026            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     12027            if (!empty($wpdb->last_error)) { return true; }
     12028            if ($registry_exists !== $registry_name) { return false; }
     12029            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     12030// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12031            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     12032            return !empty($wpdb->last_error) || !empty($source_id);
     12033        }
     12034
     12035        public function discover_origins() {
     12036            $inspection = $this->inspect_table();
     12037            if ($inspection['status'] === 'missing_table') { return []; }
     12038            return [[
     12039                'origin_key' => self::ORIGIN_KEY,
     12040                'capabilities' => ['product_views' => true, 'granularity' => 'event', 'timestamp_precision' => 'seconds', 'product_id_direct' => true],
     12041                'detection_status' => $inspection['status'],
     12042            ]];
     12043        }
     12044
     12045        public function get_origin_snapshot($origin_key) {
     12046            global $wpdb;
     12047            if ($origin_key !== self::ORIGIN_KEY) { return []; }
     12048            if ($this->inspect_table()['status'] !== 'available') {
     12049                throw new RuntimeException('Burst Statistics table is unavailable or incompatible.');
     12050            }
     12051            $posts_table_name = $wpdb->posts;
     12052            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     12053                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     12054            }
     12055            $posts_table = '`' . $posts_table_name . '`';
     12056// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12057            $max_id = statpl_db_get_var(statpl_db_prepare(
     12058                'SELECT MAX(s.ID)
     12059                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s
     12060                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.page_id AND p.post_type = %s
     12061                 WHERE s.page_id > 0 AND s.page_type = %s',
     12062                'product', 'product'
     12063            ));
     12064            if (!empty($wpdb->last_error)) {
     12065                throw new RuntimeException('Burst Statistics snapshot query failed.');
     12066            }
     12067            return [
     12068                'boundary' => ['max_id' => max(0, (int) $max_id)],
     12069                'initial_checkpoint' => null,
     12070                'captured_at' => current_time('mysql'),
     12071            ];
     12072        }
     12073
     12074        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     12075            global $wpdb;
     12076            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     12077                throw new RuntimeException('Invalid Burst Statistics origin or snapshot.');
     12078            }
     12079            if ($this->inspect_table()['status'] !== 'available') {
     12080                throw new RuntimeException('Burst Statistics table became unavailable or incompatible.');
     12081            }
     12082            $max_id = isset($snapshot['boundary']['max_id']) ? absint($snapshot['boundary']['max_id']) : -1;
     12083            if ($max_id < 0) { throw new RuntimeException('Invalid Burst Statistics snapshot boundary.'); }
     12084            $cursor_id = 0;
     12085            if (is_array($checkpoint) && isset($checkpoint['cursor'])) {
     12086                $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']);
     12087            } elseif (is_numeric($checkpoint)) { $cursor_id = absint($checkpoint); }
     12088            $limit = max(1, min(100, absint($limit)));
     12089            $posts_table_name = $wpdb->posts;
     12090            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     12091                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     12092            }
     12093            $posts_table = '`' . $posts_table_name . '`';
     12094// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12095            $rows = statpl_db_get_results(statpl_db_prepare(
     12096                'SELECT s.ID AS id, s.page_id, s.time
     12097                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s
     12098                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.page_id AND p.post_type = %s
     12099                 WHERE s.page_type = %s AND s.page_id > 0 AND s.ID > %d AND s.ID <= %d
     12100                 ORDER BY s.ID ASC
     12101                 LIMIT %d',
     12102                'product', 'product', $cursor_id, $max_id, $limit + 1
     12103            ), ARRAY_A);
     12104            if (!empty($wpdb->last_error) || !is_array($rows)) {
     12105                throw new RuntimeException('Burst Statistics batch query failed.');
     12106            }
     12107            $has_more = count($rows) > $limit;
     12108            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     12109            $next = $cursor_id;
     12110            if ($rows) { $last = end($rows); $next = absint($last['id']); }
     12111            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     12112        }
     12113
     12114        public function normalize_record($origin_key, $record) {
     12115            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; }
     12116            $record_id = isset($record['id']) ? absint($record['id']) : 0;
     12117            $product_id = isset($record['page_id']) ? absint($record['page_id']) : 0;
     12118            $timestamp = isset($record['time']) ? absint($record['time']) : 0;
     12119            if (!$record_id || !$product_id || !$timestamp || !function_exists('wp_date')) { return null; }
     12120            $datetime = wp_date('Y-m-d H:i:s', $timestamp);
     12121            if (!is_string($datetime) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $datetime)) { return null; }
     12122            return [
     12123                'product_id' => $product_id,
     12124                'source_record_key' => 'burst_statistic_' . $record_id,
     12125                'views' => 1,
     12126                'record_type' => 'event',
     12127                'period_start' => $datetime,
     12128                'period_end' => $datetime,
     12129                'cursor' => $record_id,
     12130            ];
     12131        }
     12132
     12133        public function get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event) {
     12134            return [
     12135                'source' => 'burst_statistics',
     12136                'timestamp_precision' => 'seconds',
     12137                'overlap_assessment' => 'possible_but_not_proven',
     12138                'automatic_suppression' => false,
     12139                'reason' => 'Burst supplies an exact local event timestamp and product ID, but other providers do not share its stable pageview ID or visitor identity.',
     12140            ];
     12141        }
     12142    }
     12143}
     12144
     12145add_filter('statpl_historical_view_adapters', static function ($adapters) {
     12146    if (!is_array($adapters)) { return $adapters; }
     12147    $adapter = new STATPL_Burst_Statistics_Historical_Adapter();
     12148    if ($adapter->should_register_adapter()) { $adapters[] = $adapter; }
     12149    return $adapters;
     12150}, 36);
     12151
     12152/**
     12153 * Independent Analytics adapter.
     12154 *
     12155 * Imports one row per view from independent_analytics_views and resolves the
     12156 * resource through independent_analytics_resources.singular_id/post_type.
     12157 * Independent Analytics writes viewed_at in UTC. Only the timestamp and the
     12158 * product ID are imported; visitor/session identifiers are intentionally not
     12159 * accessed.
     12160 */
     12161if (!class_exists('STATPL_Independent_Analytics_Historical_Adapter')) {
     12162    final class STATPL_Independent_Analytics_Historical_Adapter implements STATPL_Historical_View_Adapter {
     12163        const ADAPTER_KEY = 'independent_analytics';
     12164        const SOURCE_KEY = 'independent_analytics';
     12165        const ADAPTER_VERSION = '1.0.0';
     12166        const ORIGIN_KEY = 'product_view_events';
     12167
     12168        public function get_adapter_key() { return self::ADAPTER_KEY; }
     12169        public function get_source_key() { return self::SOURCE_KEY; }
     12170        public function get_display_name() { return 'Independent Analytics'; }
     12171        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     12172
     12173        private function views_table() {
     12174            global $wpdb;
     12175            return $wpdb->prefix . 'independent_analytics_views';
     12176        }
     12177
     12178        private function resources_table() {
     12179            global $wpdb;
     12180            return $wpdb->prefix . 'independent_analytics_resources';
     12181        }
     12182
     12183        private function quote_table($table) {
     12184            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     12185                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     12186            }
     12187            return '`' . $table . '`';
     12188        }
     12189
     12190        private function inspect_tables() {
     12191            global $wpdb;
     12192            $found_any = false;
     12193            $missing_any = false;
     12194            foreach ([$this->views_table() => ['id', 'resource_id', 'viewed_at'], $this->resources_table() => ['id', 'singular_id', 'post_type']] as $table => $required) {
     12195                $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     12196                if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     12197                if ($exists !== $table) {
     12198                    $missing_any = true;
     12199                    continue;
     12200                }
     12201                $found_any = true;
     12202// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12203                $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quote_table($table), ARRAY_A);
     12204                if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     12205                $names = [];
     12206                foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } }
     12207                if (array_diff($required, $names)) { return ['status' => 'incompatible_schema']; }
     12208            }
     12209            if (!$found_any) { return ['status' => 'missing_table']; }
     12210            if ($missing_any) { return ['status' => 'incompatible_schema']; }
     12211            return ['status' => 'available'];
     12212        }
     12213
     12214        public function should_register_adapter() {
     12215            global $wpdb;
     12216            $inspection = $this->inspect_tables();
     12217            if ($inspection['status'] !== 'missing_table') { return true; }
     12218            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     12219            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     12220                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     12221            }
     12222            $registry_table = '`' . $registry_name . '`';
     12223            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     12224            if (!empty($wpdb->last_error)) { return true; }
     12225            if ($registry_exists !== $registry_name) { return false; }
     12226            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     12227// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12228            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     12229            return !empty($wpdb->last_error) || !empty($source_id);
     12230        }
     12231
     12232        public function discover_origins() {
     12233            $inspection = $this->inspect_tables();
     12234            if ($inspection['status'] === 'missing_table') { return []; }
     12235            return [[
     12236                'origin_key' => self::ORIGIN_KEY,
     12237                'capabilities' => ['product_views' => true, 'granularity' => 'event', 'timestamp_precision' => 'seconds', 'product_id_direct' => true],
     12238                'detection_status' => $inspection['status'],
     12239            ]];
     12240        }
     12241
     12242        public function get_origin_snapshot($origin_key) {
     12243            global $wpdb;
     12244            if ($origin_key !== self::ORIGIN_KEY) { return []; }
     12245            if ($this->inspect_tables()['status'] !== 'available') {
     12246                throw new RuntimeException('Independent Analytics tables are unavailable or incompatible.');
     12247            }
     12248            $posts_table = $this->quote_table($wpdb->posts);
     12249// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12250            $max_id = statpl_db_get_var(statpl_db_prepare(
     12251                'SELECT MAX(v.id)
     12252                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->views_table()) . ' AS v
     12253                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->resources_table()) . ' AS r ON r.id = v.resource_id
     12254                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = r.singular_id AND p.post_type = %s
     12255                 WHERE r.post_type = %s AND r.singular_id > 0',
     12256                'product', 'product'
     12257            ));
     12258            if (!empty($wpdb->last_error)) {
     12259                throw new RuntimeException('Independent Analytics snapshot query failed.');
     12260            }
     12261            return [
     12262                'boundary' => ['max_id' => max(0, (int) $max_id)],
     12263                'initial_checkpoint' => null,
     12264                'captured_at' => current_time('mysql'),
     12265            ];
     12266        }
     12267
     12268        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     12269            global $wpdb;
     12270            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     12271                throw new RuntimeException('Invalid Independent Analytics origin or snapshot.');
     12272            }
     12273            if ($this->inspect_tables()['status'] !== 'available') {
     12274                throw new RuntimeException('Independent Analytics tables became unavailable or incompatible.');
     12275            }
     12276            $max_id = isset($snapshot['boundary']['max_id']) ? absint($snapshot['boundary']['max_id']) : -1;
     12277            if ($max_id < 0) { throw new RuntimeException('Invalid Independent Analytics snapshot boundary.'); }
     12278            $cursor_id = 0;
     12279            if (is_array($checkpoint) && isset($checkpoint['cursor'])) {
     12280                $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']);
     12281            } elseif (is_numeric($checkpoint)) { $cursor_id = absint($checkpoint); }
     12282            $limit = max(1, min(100, absint($limit)));
     12283            $posts_table = $this->quote_table($wpdb->posts);
     12284// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12285            $rows = statpl_db_get_results(statpl_db_prepare(
     12286                'SELECT v.id, r.singular_id AS product_id, v.viewed_at
     12287                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->views_table()) . ' AS v
     12288                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->resources_table()) . ' AS r ON r.id = v.resource_id
     12289                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = r.singular_id AND p.post_type = %s
     12290                 WHERE r.post_type = %s AND r.singular_id > 0 AND v.id > %d AND v.id <= %d
     12291                 ORDER BY v.id ASC
     12292                 LIMIT %d',
     12293                'product', 'product', $cursor_id, $max_id, $limit + 1
     12294            ), ARRAY_A);
     12295            if (!empty($wpdb->last_error) || !is_array($rows)) {
     12296                throw new RuntimeException('Independent Analytics batch query failed.');
     12297            }
     12298            $has_more = count($rows) > $limit;
     12299            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     12300            $next = $cursor_id;
     12301            if ($rows) { $last = end($rows); $next = absint($last['id']); }
     12302            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     12303        }
     12304
     12305        public function normalize_record($origin_key, $record) {
     12306            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; }
     12307            $record_id = isset($record['id']) ? absint($record['id']) : 0;
     12308            $product_id = isset($record['product_id']) ? absint($record['product_id']) : 0;
     12309            $viewed_at = isset($record['viewed_at']) ? trim((string) $record['viewed_at']) : '';
     12310            if (!$record_id || !$product_id || $viewed_at === '') { return null; }
     12311            $timestamp = strtotime(str_replace('T', ' ', $viewed_at) . ' UTC');
     12312            if (!$timestamp || !function_exists('wp_date')) { return null; }
     12313            $datetime = wp_date('Y-m-d H:i:s', $timestamp);
     12314            if (!is_string($datetime) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $datetime)) { return null; }
     12315            return [
     12316                'product_id' => $product_id,
     12317                'source_record_key' => 'independent_analytics_view_' . $record_id,
     12318                'views' => 1,
     12319                'record_type' => 'event',
     12320                'period_start' => $datetime,
     12321                'period_end' => $datetime,
     12322                'cursor' => $record_id,
     12323            ];
     12324        }
     12325
     12326        public function get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event) {
     12327            return [
     12328                'source' => 'independent_analytics',
     12329                'timestamp_precision' => 'seconds',
     12330                'overlap_assessment' => 'possible_but_not_proven',
     12331                'automatic_suppression' => false,
     12332                'reason' => 'Independent Analytics provides a per-view timestamp and product ID, but no stable event identifier shared with other providers.',
     12333            ];
     12334        }
     12335    }
     12336}
     12337
     12338add_filter('statpl_historical_view_adapters', static function ($adapters) {
     12339    if (!is_array($adapters)) { return $adapters; }
     12340    $adapter = new STATPL_Independent_Analytics_Historical_Adapter();
     12341    if ($adapter->should_register_adapter()) { $adapters[] = $adapter; }
     12342    return $adapters;
     12343}, 37);
     12344
     12345/**
     12346 * PulseViews adapter.
     12347 *
     12348 * Uses disjoint date windows: completed daily aggregates through two days ago,
     12349 * then non-bot raw events after that boundary. Weekly/monthly rollups and the
     12350 * lifetime totals cache are never imported, preventing overlap. PulseViews
     12351 * prunes raw events after 90 days but retains its daily aggregate history.
     12352 */
     12353if (!class_exists('STATPL_PulseViews_Historical_Adapter')) {
     12354    final class STATPL_PulseViews_Historical_Adapter implements STATPL_Historical_View_Adapter {
     12355        const ADAPTER_KEY = 'pulseviews';
     12356        const SOURCE_KEY = 'pulseviews';
     12357        const ADAPTER_VERSION = '1.0.0';
     12358        const DAILY_ORIGIN = 'daily_aggregates';
     12359        const RAW_ORIGIN = 'recent_raw_views';
     12360        private $snapshot_through_date = null;
     12361        private $snapshot_captured_at = null;
     12362
     12363        private function get_snapshot_through_date() {
     12364            if ($this->snapshot_through_date === null) {
     12365                $this->snapshot_through_date = current_datetime()->modify('-2 days')->format('Y-m-d');
     12366            }
     12367            return $this->snapshot_through_date;
     12368        }
     12369
     12370        private function get_snapshot_captured_at() {
     12371            if ($this->snapshot_captured_at === null) {
     12372                $this->snapshot_captured_at = current_time('mysql');
     12373            }
     12374            return $this->snapshot_captured_at;
     12375        }
     12376
     12377        public function get_adapter_key() { return self::ADAPTER_KEY; }
     12378        public function get_source_key() { return self::SOURCE_KEY; }
     12379        public function get_display_name() { return 'PulseViews'; }
     12380        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     12381
     12382        private function table_name($kind) {
     12383            global $wpdb;
     12384            if (!in_array($kind, ['daily', 'raw'], true)) {
     12385                throw new InvalidArgumentException('Unsupported PulseViews table kind.');
     12386            }
     12387            return $wpdb->prefix . ($kind === 'daily' ? 'pulseviews_aggregates' : 'pulseviews_views');
     12388        }
     12389
     12390        private function quote_table($table) {
     12391            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     12392                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     12393            }
     12394            return '`' . $table . '`';
     12395        }
     12396
     12397        private function inspect_table($kind) {
     12398            global $wpdb;
     12399            $table = $this->table_name($kind);
     12400            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     12401            if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     12402            if ($exists !== $table) { return ['status' => 'missing_table']; }
     12403// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12404            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quote_table($table), ARRAY_A);
     12405            if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     12406            $names = [];
     12407            foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } }
     12408            $required = $kind === 'daily'
     12409                ? ['id', 'post_id', 'period_date', 'period_type', 'view_count']
     12410                : ['id', 'post_id', 'viewed_at', 'is_bot'];
     12411            if (array_diff($required, $names)) { return ['status' => 'incompatible_schema']; }
     12412            return ['status' => 'available'];
     12413        }
     12414
     12415        public function should_register_adapter() {
     12416            global $wpdb;
     12417            $daily = $this->inspect_table('daily');
     12418            $raw = $this->inspect_table('raw');
     12419            if ($daily['status'] !== 'missing_table' || $raw['status'] !== 'missing_table') { return true; }
     12420            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     12421            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     12422                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     12423            }
     12424            $registry_table = '`' . $registry_name . '`';
     12425            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     12426            if (!empty($wpdb->last_error)) { return true; }
     12427            if ($registry_exists !== $registry_name) { return false; }
     12428            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     12429// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12430            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     12431            return !empty($wpdb->last_error) || !empty($source_id);
     12432        }
     12433
     12434        public function discover_origins() {
     12435            $origins = [];
     12436            $daily = $this->inspect_table('daily');
     12437            if ($daily['status'] !== 'missing_table') {
     12438                $origins[] = [
     12439                    'origin_key' => self::DAILY_ORIGIN,
     12440                    'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'window' => 'through_two_days_ago'],
     12441                    'detection_status' => $daily['status'],
     12442                ];
     12443            }
     12444            $raw = $this->inspect_table('raw');
     12445            if ($raw['status'] !== 'missing_table') {
     12446                $origins[] = [
     12447                    'origin_key' => self::RAW_ORIGIN,
     12448                    'capabilities' => ['product_views' => true, 'granularity' => 'event', 'window' => 'after_daily_aggregate_boundary', 'bot_filtered' => true],
     12449                    'detection_status' => $raw['status'],
     12450                ];
     12451            }
     12452            return $origins;
     12453        }
     12454
     12455        public function get_origin_snapshot($origin_key) {
     12456            global $wpdb;
     12457            $through_date = $this->get_snapshot_through_date();
     12458            $posts_table = $this->quote_table($wpdb->posts);
     12459            if ($origin_key === self::DAILY_ORIGIN) {
     12460                if ($this->inspect_table('daily')['status'] !== 'available') {
     12461                    throw new RuntimeException('PulseViews daily aggregate table is unavailable or incompatible.');
     12462                }
     12463// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12464                $max_id = statpl_db_get_var(statpl_db_prepare(
     12465                    'SELECT MAX(a.id)
     12466                     FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->table_name('daily')) . ' AS a
     12467                     INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = a.post_id AND p.post_type = %s
     12468                     WHERE a.period_type = %s AND a.period_date <= %s',
     12469                    'product', 'day', $through_date
     12470                ));
     12471                if (!empty($wpdb->last_error)) { throw new RuntimeException('PulseViews daily snapshot query failed.'); }
     12472                return ['boundary' => ['max_id' => max(0, (int) $max_id), 'through_date' => $through_date], 'initial_checkpoint' => null, 'captured_at' => current_time('mysql')];
     12473            }
     12474            if ($origin_key === self::RAW_ORIGIN) {
     12475                if ($this->inspect_table('raw')['status'] !== 'available') {
     12476                    throw new RuntimeException('PulseViews raw views table is unavailable or incompatible.');
     12477                }
     12478                $after_datetime = $through_date . ' 23:59:59';
     12479                $captured_at = $this->get_snapshot_captured_at();
     12480// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12481                $max_id = statpl_db_get_var(statpl_db_prepare(
     12482                    'SELECT MAX(v.id)
     12483                     FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->table_name('raw')) . ' AS v
     12484                     INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = v.post_id AND p.post_type = %s
     12485                     WHERE v.is_bot = 0 AND v.viewed_at > %s AND v.viewed_at <= %s',
     12486                    'product', $after_datetime, $captured_at
     12487                ));
     12488                if (!empty($wpdb->last_error)) { throw new RuntimeException('PulseViews raw snapshot query failed.'); }
     12489                return ['boundary' => ['max_id' => max(0, (int) $max_id), 'after_datetime' => $after_datetime, 'captured_at' => $captured_at], 'initial_checkpoint' => null, 'captured_at' => $captured_at];
     12490            }
     12491            return [];
     12492        }
     12493
     12494        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     12495            global $wpdb;
     12496            if (!is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     12497                throw new RuntimeException('Invalid PulseViews snapshot.');
     12498            }
     12499            $cursor_id = 0;
     12500            if (is_array($checkpoint) && isset($checkpoint['cursor'])) { $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']); }
     12501            elseif (is_numeric($checkpoint)) { $cursor_id = absint($checkpoint); }
     12502            $max_id = isset($snapshot['boundary']['max_id']) ? absint($snapshot['boundary']['max_id']) : -1;
     12503            if ($max_id < 0) { throw new RuntimeException('Invalid PulseViews snapshot boundary.'); }
     12504            $limit = max(1, min(100, absint($limit)));
     12505            $posts_table = $this->quote_table($wpdb->posts);
     12506            if ($origin_key === self::DAILY_ORIGIN) {
     12507                if ($this->inspect_table('daily')['status'] !== 'available') { throw new RuntimeException('PulseViews daily aggregate table became unavailable.'); }
     12508                $through_date = isset($snapshot['boundary']['through_date']) ? (string) $snapshot['boundary']['through_date'] : '';
     12509                $parsed = DateTime::createFromFormat('!Y-m-d', $through_date);
     12510                if (!$parsed || $parsed->format('Y-m-d') !== $through_date) { throw new RuntimeException('Invalid PulseViews daily boundary.'); }
     12511// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12512                $rows = statpl_db_get_results(statpl_db_prepare(
     12513                    'SELECT a.id, a.post_id, a.period_date, a.view_count
     12514                     FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->table_name('daily')) . ' AS a
     12515                     INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = a.post_id AND p.post_type = %s
     12516                     WHERE a.period_type = %s AND a.id > %d AND a.id <= %d AND a.period_date <= %s
     12517                     ORDER BY a.id ASC
     12518                     LIMIT %d',
     12519                    'product', 'day', $cursor_id, $max_id, $through_date, $limit + 1
     12520                ), ARRAY_A);
     12521            } elseif ($origin_key === self::RAW_ORIGIN) {
     12522                if ($this->inspect_table('raw')['status'] !== 'available') { throw new RuntimeException('PulseViews raw views table became unavailable.'); }
     12523                $after_datetime = isset($snapshot['boundary']['after_datetime']) ? (string) $snapshot['boundary']['after_datetime'] : '';
     12524                $captured_at = isset($snapshot['boundary']['captured_at']) ? (string) $snapshot['boundary']['captured_at'] : '';
     12525                if ($after_datetime === '' || $captured_at === '') { throw new RuntimeException('Invalid PulseViews raw boundary.'); }
     12526// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12527                $rows = statpl_db_get_results(statpl_db_prepare(
     12528                    'SELECT v.id, v.post_id, v.viewed_at
     12529                     FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->table_name('raw')) . ' AS v
     12530                     INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = v.post_id AND p.post_type = %s
     12531                     WHERE v.is_bot = 0 AND v.id > %d AND v.id <= %d AND v.viewed_at > %s AND v.viewed_at <= %s
     12532                     ORDER BY v.id ASC
     12533                     LIMIT %d',
     12534                    'product', $cursor_id, $max_id, $after_datetime, $captured_at, $limit + 1
     12535                ), ARRAY_A);
     12536            } else {
     12537                throw new RuntimeException('Unknown PulseViews origin.');
     12538            }
     12539            if (!empty($wpdb->last_error) || !is_array($rows)) { throw new RuntimeException('PulseViews batch query failed.'); }
     12540            $has_more = count($rows) > $limit;
     12541            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     12542            $next = $cursor_id;
     12543            if ($rows) { $last = end($rows); $next = absint($last['id']); }
     12544            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     12545        }
     12546
     12547        public function normalize_record($origin_key, $record) {
     12548            if (!is_array($record)) { return null; }
     12549            $record_id = isset($record['id']) ? absint($record['id']) : 0;
     12550            $product_id = isset($record['post_id']) ? absint($record['post_id']) : 0;
     12551            if (!$record_id || !$product_id) { return null; }
     12552            if ($origin_key === self::DAILY_ORIGIN) {
     12553                $date = isset($record['period_date']) ? (string) $record['period_date'] : '';
     12554                $views_raw = $record['view_count'] ?? null;
     12555                if (!preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; }
     12556                $parsed = DateTime::createFromFormat('!Y-m-d', $date);
     12557                $errors = DateTime::getLastErrors();
     12558                if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; }
     12559                $views = (int) $views_raw;
     12560                $period_start = $date . ' 00:00:00';
     12561                $period_end = $date . ' 23:59:59';
     12562                return ['product_id' => $product_id, 'source_record_key' => 'pulseviews_day_' . $product_id . '_' . $date, 'views' => $views, 'record_type' => 'aggregate', 'period_start' => $period_start, 'period_end' => $period_end, 'cursor' => $record_id];
     12563            }
     12564            if ($origin_key === self::RAW_ORIGIN) {
     12565                $viewed_at = isset($record['viewed_at']) ? (string) $record['viewed_at'] : '';
     12566                $parsed = DateTime::createFromFormat('!Y-m-d H:i:s', $viewed_at);
     12567                $errors = DateTime::getLastErrors();
     12568                if (!$parsed || $parsed->format('Y-m-d H:i:s') !== $viewed_at || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; }
     12569                return ['product_id' => $product_id, 'source_record_key' => 'pulseviews_view_' . $record_id, 'views' => 1, 'record_type' => 'event', 'period_start' => $viewed_at, 'period_end' => $viewed_at, 'cursor' => $record_id];
     12570            }
     12571            return null;
     12572        }
     12573
     12574        public function get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event) {
     12575            return [
     12576                'source' => 'pulseviews',
     12577                'origin' => $origin_key,
     12578                'overlap_assessment' => 'possible_but_not_proven',
     12579                'automatic_suppression' => false,
     12580                'reason' => 'PulseViews event rows have local timestamps but no stable cross-provider event identifier; daily aggregates are matched only on exact product/date/count dimensions.',
     12581            ];
     12582        }
     12583    }
     12584}
     12585
     12586add_filter('statpl_historical_view_adapters', static function ($adapters) {
     12587    if (!is_array($adapters)) { return $adapters; }
     12588    $adapter = new STATPL_PulseViews_Historical_Adapter();
     12589    if ($adapter->should_register_adapter()) { $adapters[] = $adapter; }
     12590    return $adapters;
     12591}, 38);
  • statora-plus/trunk/readme.txt

    r3790156 r3795175  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 0.11.46
     7Stable tag: 0.11.47
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    8282== Changelog ==
    8383
     84= 0.11.47 =
     85* Harden historical product-view source queries and validate adapter schema/table selections.
     86* Use prepared integer limits and WordPress timezone-aware retry timestamps.
     87* Use WordPress URL parsing for Statify source URLs.
     88
    8489= 0.11.46 =
    8590* Place narrowly scoped Plugin Check security-rule annotations on the database-call lines that use constructor-generated table identifiers.
  • statora-plus/trunk/statora-plus.php

    r3788367 r3795175  
    33 * Plugin Name: Statora Plus
    44 * Description: View key WooCommerce product statistics in one clear dashboard.
    5  * Version: 0.11.46
     5 * Version: 0.11.47
    66 * Author: Panagiotis Pavlidis
    77 * Author URI: https://gr.linkedin.com/in/frozenfiregr
    … …  
    1616if (!defined('ABSPATH')) { exit; }
    1717
     18
     19/**
     20 * Audited wpdb boundary for Statora SQL.
     21 *
     22 * All runtime data values must already be passed through statpl_db_prepare()
     23 * with placeholders. Table/column identifiers are not SQL values and must be
     24 * generated from the WordPress prefix plus fixed plugin/provider suffixes, or
     25 * validated against a strict allowlist before reaching this boundary.
     26 *
     27 * These wrappers keep direct wpdb access in one reviewable location. They do
     28 * not add caching: importer transactions, locks, schema checks and live metric
     29 * reads require their existing consistency and freshness semantics.
     30 */
     31if (!function_exists('statpl_db_prepare')) {
     32    function statpl_db_prepare($query, ...$args) {
     33        global $wpdb;
     34        if (!is_object($wpdb) || !method_exists($wpdb, 'prepare') || !is_string($query)) {
     35            return false;
     36        }
     37        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Centralized preparation boundary; callers provide fixed SQL structure, validated identifiers and runtime values as placeholders.
     38        return call_user_func_array(array($wpdb, 'prepare'), array_merge(array($query), $args));
     39    }
     40}
     41
     42if (!function_exists('statpl_db_query')) {
     43    function statpl_db_query($query) {
     44        global $wpdb;
     45        if (!is_object($wpdb) || !method_exists($wpdb, 'query') || !is_string($query)) {
     46            return false;
     47        }
     48        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized execution boundary; query strings are prepared by statpl_db_prepare() or consist solely of fixed DDL/SQL and validated identifiers.
     49        return $wpdb->query($query);
     50    }
     51}
     52
     53if (!function_exists('statpl_db_get_var')) {
     54    function statpl_db_get_var($query, ...$args) {
     55        global $wpdb;
     56        if (!is_object($wpdb) || !method_exists($wpdb, 'get_var') || !is_string($query)) {
     57            return null;
     58        }
     59        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized read boundary; callers use prepared values and validated internal identifiers.
     60        return $wpdb->get_var($query, ...$args);
     61    }
     62}
     63
     64if (!function_exists('statpl_db_get_row')) {
     65    function statpl_db_get_row($query, ...$args) {
     66        global $wpdb;
     67        if (!is_object($wpdb) || !method_exists($wpdb, 'get_row') || !is_string($query)) {
     68            return null;
     69        }
     70        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized read boundary; callers use prepared values and validated internal identifiers.
     71        return $wpdb->get_row($query, ...$args);
     72    }
     73}
     74
     75if (!function_exists('statpl_db_get_results')) {
     76    function statpl_db_get_results($query, ...$args) {
     77        global $wpdb;
     78        if (!is_object($wpdb) || !method_exists($wpdb, 'get_results') || !is_string($query)) {
     79            return null;
     80        }
     81        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized read boundary; callers use prepared values and validated internal identifiers.
     82        return $wpdb->get_results($query, ...$args);
     83    }
     84}
     85
     86if (!function_exists('statpl_db_get_col')) {
     87    function statpl_db_get_col($query, ...$args) {
     88        global $wpdb;
     89        if (!is_object($wpdb) || !method_exists($wpdb, 'get_col') || !is_string($query)) {
     90            return null;
     91        }
     92        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Centralized read boundary; callers use prepared values and validated internal identifiers.
     93        return $wpdb->get_col($query, ...$args);
     94    }
     95}
     96
     97
     98if (!function_exists('statpl_db_update')) {
     99    function statpl_db_update($table, $data, $where, $format = null, $where_format = null) {
     100        global $wpdb;
     101        if (!is_object($wpdb) || !method_exists($wpdb, 'update')) {
     102            return false;
     103        }
     104        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Centralized CRUD boundary; table identifiers are internal plugin properties and data values use wpdb format handling.
     105        return $wpdb->update($table, $data, $where, $format, $where_format);
     106    }
     107}
     108
     109if (!function_exists('statpl_db_insert')) {
     110    function statpl_db_insert($table, $data, $format = null) {
     111        global $wpdb;
     112        if (!is_object($wpdb) || !method_exists($wpdb, 'insert')) {
     113            return false;
     114        }
     115        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Centralized CRUD boundary; table identifiers are internal plugin properties and data values use wpdb format handling.
     116        return $wpdb->insert($table, $data, $format);
     117    }
     118}
     119
     120if (!function_exists('statpl_db_delete')) {
     121    function statpl_db_delete($table, $where, $where_format = null) {
     122        global $wpdb;
     123        if (!is_object($wpdb) || !method_exists($wpdb, 'delete')) {
     124            return false;
     125        }
     126        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Centralized CRUD boundary; table identifiers are internal plugin properties and values use wpdb format handling.
     127        return $wpdb->delete($table, $where, $where_format);
     128    }
     129}
     130
     131/**
     132 * Contract implemented by source-specific historical product-view adapters.
     133 * Adapters are supplied through the `statpl_historical_view_adapters` filter.
     134 *
     135 * `discover_origins()` must return only historical product-view origins; general
     136 * page views, visits and non-product content must never be advertised here.
     137 * `get_origin_snapshot()` must return an immutable, non-empty `boundary` descriptor.
     138 * Every `get_product_view_batch()` result must include `boundary_applied => true`;
     139 * the adapter must enforce that snapshot boundary in its source query, return no
     140 * more than the requested limit, and advance `next_checkpoint` when `has_more` is true.
     141 * `normalize_record()` may explicitly return `['skip' => true, 'source_record_key' => ..., 'cursor' => ...]`
     142 * for a valid source row that is outside product scope. The core validates the key/cursor,
     143 * clears any stale per-row failure, advances the batch cursor, and does not create an event
     144 * or failure-ledger entry. Invalid product records must still return null and follow retries.
     145 *
     146 * Optional adapter extension: `get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event)`.
     147 * It may return a small, JSON-serializable evidence array describing source-specific
     148 * overlap signals. Its output is audit evidence only: it cannot suppress counts or
     149 * alter the generic import decision. Automatic suppression remains limited to the
     150 * generic exact-match rule requiring a shared fingerprint and identical dimensions.
     151 */
     152if (!interface_exists('STATPL_Historical_View_Adapter')) {
     153    interface STATPL_Historical_View_Adapter {
     154        public function get_adapter_key();
     155        public function get_source_key();
     156        public function get_display_name();
     157        public function get_adapter_version();
     158        public function discover_origins();
     159        public function get_origin_snapshot($origin_key);
     160        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit);
     161        public function normalize_record($origin_key, $record);
     162    }
     163}
     164
    18165final class STATPL_Core {
    19     const VERSION = '0.11.46';
     166    const VERSION = '0.11.47';
     167    const VIEW_IMPORT_SCHEMA_VERSION = '1.0.0';
     168    const VIEW_IMPORT_STATE_SCHEMA_VERSION = '1.1.0';
     169    const VIEW_IMPORT_EVENTS_SCHEMA_VERSION = '1.3.0';
     170    const VIEW_IMPORT_FAILURES_SCHEMA_VERSION = '1.3.0';
     171    const VIEW_SOURCE_SCHEMA_VERSION = '1.3.4';
     172    const HISTORY_REPORT_SCHEMA_VERSION = '1.0.0';
    20173    const NONCE_ACTION = 'statpl_record_view';
    21174    private static $instance;
    … …  
    24177    private $order_events_table;
    25178    private $order_items_table;
    26 
    27     public static function instance() {
     179    private $view_imports_table;
     180   private $view_import_failures_table;
     181
     182   private $view_import_state_table;
     183   private $view_import_events_table;
     184   private $view_sources_table;
     185   private $view_source_origins_table;
     186   private $view_source_state_table;
     187   private $view_source_events_table;
     188   private $view_source_failures_table;
     189   private $view_source_reconciliation_table;
     190   private $view_source_reconciliation_history_table;
     191   private $historical_import_report_table;
     192   private $historical_view_adapters = [];
     193   public static function instance() {
    28194        if (!self::$instance) self::$instance = new self();
    29195        return self::$instance;
    … …  
    35201        $this->order_events_table = $wpdb->prefix . 'statpl_product_metric_events';
    36202        $this->order_items_table = $wpdb->prefix . 'statpl_order_product_snapshot';
     203        $this->view_imports_table = $wpdb->prefix . 'statpl_view_imports';
     204        $this->view_import_state_table = $wpdb->prefix . 'statpl_view_import_state';
     205        $this->view_import_events_table = $wpdb->prefix . 'statpl_view_import_events';
     206        $this->view_import_failures_table = $wpdb->prefix . 'statpl_view_import_failures';
     207        $this->view_sources_table = $wpdb->prefix . 'statpl_view_sources';
     208        $this->view_source_origins_table = $wpdb->prefix . 'statpl_view_source_origins';
     209        $this->view_source_state_table = $wpdb->prefix . 'statpl_view_source_state';
     210        $this->view_source_events_table = $wpdb->prefix . 'statpl_view_source_events';
     211        $this->view_source_failures_table = $wpdb->prefix . 'statpl_view_source_failures';
     212        $this->view_source_reconciliation_table = $wpdb->prefix . 'statpl_view_source_reconciliation';
     213        $this->view_source_reconciliation_history_table = $wpdb->prefix . 'statpl_view_source_reconciliation_history';
     214        $this->historical_import_report_table = $wpdb->prefix . 'statpl_historical_import_report';
    37215        register_activation_hook(__FILE__, [$this, 'activate']);
     216        register_deactivation_hook(__FILE__, [$this, 'deactivate']);
     217        add_action('admin_post_statpl_prepare_deactivation', [$this, 'prepare_deactivation']);
    38218        add_action('plugins_loaded', [$this, 'init']);
    39219    }
     220    /**
     221     * Intercept the Plugins-screen deactivation action and persist the explicit
     222     * administrator choice before WordPress invokes the deactivation hook.
     223     */
     224    public function prepare_deactivation() {
     225        if (!current_user_can('activate_plugins')) {
     226            wp_die(esc_html__('Δεν έχεις δικαίωμα εκτέλεσης αυτής της ενέργειας.', 'statora-plus'));
     227        }
     228        check_admin_referer('statpl_prepare_deactivation');
     229
     230        $choice = isset($_GET['choice']) ? sanitize_key(wp_unslash($_GET['choice'])) : '';
     231        if (!in_array($choice, ['retain', 'delete'], true)) {
     232            wp_die(esc_html__('Μη έγκυρη επιλογή διατήρησης δεδομένων.', 'statora-plus'));
     233        }
     234
     235        $redirect = isset($_GET['redirect_to']) ? esc_url_raw(wp_unslash($_GET['redirect_to'])) : '';
     236        $parts = wp_parse_url($redirect);
     237        $query = [];
     238        if (!is_array($parts) || empty($parts['path']) || basename($parts['path']) !== 'plugins.php') {
     239            wp_die(esc_html__('Μη έγκυρος προορισμός απενεργοποίησης.', 'statora-plus'));
     240        }
     241        if (!empty($parts['query'])) {
     242            parse_str($parts['query'], $query);
     243        }
     244        if (($query['action'] ?? '') !== 'deactivate' || ($query['plugin'] ?? '') !== plugin_basename(__FILE__)) {
     245            wp_die(esc_html__('Η ενέργεια δεν αφορά το Statora Plus.', 'statora-plus'));
     246        }
     247
     248        update_option('statpl_deactivation_choice', $choice, false);
     249        wp_safe_redirect($redirect);
     250        exit;
     251    }
     252
     253    /** Stop Statora-owned workers and either retain or remove Statora-owned data. */
     254    public function deactivate() {
     255        global $wpdb;
     256
     257        $choice = (string) get_option('statpl_deactivation_choice', '');
     258        delete_option('statpl_deactivation_choice');
     259
     260        $hooks = [
     261            'statpl_import_batch',
     262            'statpl_view_import_batch',
     263            'statpl_generic_view_import_batch',
     264            'statpl_generic_view_snapshot_retry',
     265            'statpl_generic_view_retry_failed_records',
     266            'statpl_sync_order',
     267            'statpl_reconcile_legacy_aggregate_fingerprints',
     268        ];
     269        foreach ($hooks as $hook) {
     270            wp_clear_scheduled_hook($hook);
     271            if (function_exists('as_unschedule_all_actions')) {
     272                as_unschedule_all_actions($hook, null, 'statpl-plus');
     273            }
     274        }
     275
     276        if ($choice === 'retain') {
     277            update_option('statpl_retained_deactivation_at', current_time('mysql'), false);
     278            update_option('statpl_data_lifecycle_state', 'retained', false);
     279            delete_transient('statpl_order_stats_v1');
     280            delete_transient('statpl_global_summary_v2');
     281            return;
     282        }
     283
     284        // A missing/invalid choice must never silently destroy data.
     285        if ($choice !== 'delete') {
     286            update_option('statpl_deactivation_choice_missing', '1', false);
     287            return;
     288        }
     289
     290        $tables = [
     291            $this->views_table,
     292            $this->events_table,
     293            $this->order_events_table,
     294            $this->order_items_table,
     295            $this->view_imports_table,
     296            $this->view_import_state_table,
     297            $this->view_import_events_table,
     298            $this->view_import_failures_table,
     299            $this->view_sources_table,
     300            $this->view_source_origins_table,
     301            $this->view_source_state_table,
     302            $this->view_source_events_table,
     303            $this->view_source_failures_table,
     304            $this->view_source_reconciliation_table,
     305            $this->view_source_reconciliation_history_table,
     306            $this->historical_import_report_table,
     307        ];
     308        foreach ($tables as $table) {
     309            // Table names are built exclusively from the active WordPress prefix
     310            // and fixed Statora suffixes declared in the constructor. Validate the
     311            // complete identifier before placing it in a DDL statement; identifiers
     312            // cannot be supplied as ordinary prepared-statement value placeholders.
     313            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     314                continue;
     315            }
     316            $quoted_table = '`' . $table . '`';
     317            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DDL identifiers cannot use value placeholders; the identifier is constrained to ASCII letters, digits and underscores and is quoted as one identifier.
     318            statpl_db_query('DROP TABLE IF EXISTS ' . $quoted_table);
     319        }
     320
     321        // Remove only Statora's reserved option/transient namespaces.
     322        $option_like = $wpdb->esc_like('statpl_') . '%';
     323        statpl_db_query(statpl_db_prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $option_like));
     324        $transient_like = $wpdb->esc_like('_transient_statpl_') . '%';
     325        $timeout_like = $wpdb->esc_like('_transient_timeout_statpl_') . '%';
     326        statpl_db_query(statpl_db_prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $transient_like, $timeout_like));
     327        if (function_exists('wp_cache_flush_group')) {
     328            wp_cache_flush_group('statpl_plus');
     329        }
     330    }
     331
     332    /** Rebuild the historical import window after an explicit retain/reactivate cycle. */
     333    private function resume_retained_history_gap() {
     334        global $wpdb;
     335
     336        $deactivated_at = (string) get_option('statpl_retained_deactivation_at', '');
     337        if ($deactivated_at === '') {
     338            return ['status' => 'not_required'];
     339        }
     340
     341        $lock = $this->acquire_generic_advisory_lock('statpl_generic_adapter_discovery');
     342        if (empty($lock['acquired'])) {
     343            return ['status' => 'lock_unavailable'];
     344        }
     345
     346        $reset = 0;
     347        $failed = 0;
     348        $scheduled_origins = [];
     349        try {
     350            $origins = statpl_db_get_results(
     351                "SELECT source_id, origin_key, adapter_key FROM {$this->view_source_origins_table} WHERE detection_status = 'available'",
     352                ARRAY_A
     353            );
     354            if (!empty($wpdb->last_error) || !is_array($origins)) {
     355                return ['status' => 'database_error'];
     356            }
     357
     358            foreach ($origins as $origin) {
     359                if (!$this->generic_advisory_lock_session_is_current($lock)) {
     360                    return ['status' => 'lock_session_lost', 'reset' => $reset, 'failed' => $failed];
     361                }
     362                $adapter = $this->get_historical_view_adapter((string) $origin['adapter_key']);
     363                if (!$adapter) {
     364                    $failed++;
     365                    continue;
     366                }
     367                try {
     368                    $snapshot = $adapter->get_origin_snapshot((string) $origin['origin_key']);
     369                } catch (Throwable $exception) {
     370                    $failed++;
     371                    continue;
     372                }
     373                if (!$this->generic_advisory_lock_session_is_current($lock)) {
     374                    return ['status' => 'lock_session_lost', 'reset' => $reset, 'failed' => $failed];
     375                }
     376                if (!is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     377                    $failed++;
     378                    continue;
     379                }
     380                if (!isset($snapshot['captured_at'])) {
     381                    $snapshot['captured_at'] = current_time('mysql');
     382                }
     383                $initial = array_key_exists('initial_checkpoint', $snapshot) ? $snapshot['initial_checkpoint'] : null;
     384                if ($initial !== null && !$this->is_valid_generic_historical_cursor($initial)) {
     385                    $failed++;
     386                    continue;
     387                }
     388                unset($snapshot['initial_checkpoint']);
     389                $saved = $this->update_view_source_origin_state(
     390                    (int) $origin['source_id'],
     391                    (string) $origin['origin_key'],
     392                    [
     393                        'snapshot_data' => $snapshot,
     394                        'checkpoint_data' => ['cursor' => $initial, 'processed' => 0, 'snapshot_captured_at' => $snapshot['captured_at']],
     395                        'status' => 'pending',
     396                        'last_error' => null,
     397                        'started_at' => null,
     398                        'completed_at' => null,
     399                    ],
     400                    $lock
     401                );
     402                if (empty($saved['status']) || $saved['status'] !== 'updated') {
     403                    $failed++;
     404                    continue;
     405                }
     406                $reset++;
     407                $scheduled_origins[] = [(int) $origin['source_id'], (string) $origin['origin_key']];
     408            }
     409        } finally {
     410            $this->release_generic_advisory_lock($lock, 'retained_history_gap');
     411        }
     412
     413        foreach ($scheduled_origins as $scheduled_origin) {
     414            $this->schedule_generic_historical_view_import_batch(15, $scheduled_origin[0], $scheduled_origin[1]);
     415        }
     416
     417        // SlimStat uses a separate importer and event ledger. Rewind its cursor to
     418        // the beginning while extending both source boundaries; its unique event
     419        // ledger prevents already-applied records from being counted twice.
     420        $slimstat_state = statpl_db_get_var(statpl_db_prepare(
     421            "SELECT source FROM {$this->view_import_state_table} WHERE source = %s",
     422            'slimstat'
     423        ));
     424        if ($slimstat_state === 'slimstat') {
     425            $snapshot = $this->get_slimstat_view_snapshot();
     426            if (($snapshot['status'] ?? '') === 'available') {
     427                $updated = statpl_db_update(
     428                    $this->view_import_state_table,
     429                    [
     430                        'snapshot_max_id' => max((int) $snapshot['snapshot']['active'], (int) $snapshot['snapshot']['archive']),
     431                        'snapshot_active_max_id' => (int) $snapshot['snapshot']['active'],
     432                        'snapshot_archive_max_id' => (int) $snapshot['snapshot']['archive'],
     433                        'last_processed_id' => 0,
     434                        'status' => 'pending',
     435                        'started_at' => null,
     436                        'completed_at' => null,
     437                        'updated_at' => current_time('mysql'),
     438                    ],
     439                    ['source' => 'slimstat'],
     440                    ['%d', '%d', '%d', '%d', '%s', '%s', '%s', '%s'],
     441                    ['%s']
     442                );
     443                if ($updated === false) {
     444                    $failed++;
     445                }
     446            } else {
     447                $failed++;
     448            }
     449        }
     450
     451        if ($failed === 0) {
     452            delete_option('statpl_retained_deactivation_at');
     453            update_option('statpl_data_lifecycle_state', 'active', false);
     454        }
     455        return ['status' => $failed === 0 ? 'reset' : 'partial', 'reset' => $reset, 'failed' => $failed];
     456    }
     457
    40458    public function activate() {
    41459        global $wpdb;
    … …  
    47465            total_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
    48466            PRIMARY KEY  (product_id)
     467        ) $collate;");
     468                dbDelta("CREATE TABLE {$this->view_imports_table} (
     469            id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     470            source VARCHAR(50) NOT NULL,
     471            product_id BIGINT(20) UNSIGNED NOT NULL,
     472            imported_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     473            imported_at DATETIME NOT NULL,
     474            PRIMARY KEY  (id),
     475            UNIQUE KEY source_product (source, product_id),
     476            KEY imported_at (imported_at)
    49477        ) $collate;");
    50478        dbDelta("CREATE TABLE {$this->events_table} (
    … …  
    59487        // On a fresh install, avoid issuing SHOW INDEX against a table that has not yet been created.
    60488        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema detection is required before the conditional index migration; the identifier is built from the WordPress prefix and a fixed suffix.
    61         $events_table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($this->order_events_table)));
     489        $events_table_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($this->order_events_table)));
    62490        if ($events_table_exists === $this->order_events_table) {
    63491            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is built from the WordPress prefix and a fixed plugin suffix; this conditional migration checks the obsolete index.
    64             $old_index = $wpdb->get_var($wpdb->prepare("SHOW INDEX FROM {$this->order_events_table} WHERE Key_name = %s", 'order_product_event'));
     492            $old_index = statpl_db_get_var(statpl_db_prepare("SHOW INDEX FROM {$this->order_events_table} WHERE Key_name = %s", 'order_product_event'));
    65493            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is built from the WordPress prefix and a fixed plugin suffix; this conditional migration removes only the obsolete index.
    66             if ($old_index) { $wpdb->query("ALTER TABLE {$this->order_events_table} DROP INDEX order_product_event"); }
     494            if ($old_index) { statpl_db_query("ALTER TABLE {$this->order_events_table} DROP INDEX order_product_event"); }
    67495        }
    68496        dbDelta("CREATE TABLE {$this->order_events_table} (
    … …  
    94522            KEY status_created (order_status, created_at)
    95523        ) $collate;");
    96         // Rebuild order snapshots once after an upgrade so completed_at and refund attribution
    97         // are populated consistently for orders already present in the store.
     524        // dbDelta can fail without throwing (for example during a transient
     525        // database outage). Verify the core table structures before advancing
     526        // the global DB version; otherwise a partial install would be treated as
     527        // current and would not be retried on the next request.
     528        $required_core_columns = [
     529            $this->views_table => ['product_id', 'total_views'],
     530            $this->view_imports_table => ['id', 'source', 'product_id', 'imported_views', 'imported_at'],
     531            $this->events_table => ['id', 'product_id', 'viewed_at'],
     532            $this->order_events_table => ['id', 'order_id', 'product_id', 'event_type', 'event_ref_id', 'event_at'],
     533            $this->order_items_table => ['order_id', 'product_id', 'order_status', 'created_at', 'paid_at', 'completed_at', 'quantity', 'net_quantity', 'net_revenue', 'has_refund'],
     534        ];
     535        foreach ($required_core_columns as $table => $columns) {
     536            if (!$this->verify_historical_table_columns($table, $columns)) {
     537                return false;
     538            }
     539        }
     540
     541        // Upgrade side effects must run only after every core table has been
     542        // verified. Previously these resets happened before verification, so a
     543        // partial dbDelta/database outage could repeatedly rewind an in-progress
     544        // order import even though the schema upgrade had not succeeded.
    98545        if ($previous_version !== '' && version_compare($previous_version, self::VERSION, '<')) {
    99546            update_option('statpl_import_complete', 0, false);
    … …  
    102549            delete_transient('statpl_global_summary_v2');
    103550        }
     551
    104552        update_option('statpl_db_version', self::VERSION);
    105     }
    106     public function init() {
    107         if (get_option('statpl_db_version') !== self::VERSION) {
    108             $this->activate();
    109         }
     553        if ((string) get_option('statpl_db_version', '') !== self::VERSION) {
     554            return false;
     555        }
     556        return true;
     557    }
     558    /** Persist a schema marker and verify the stored value before proceeding. */
     559    private function persist_schema_version_marker($option_name, $version) {
     560        update_option($option_name, $version, false);
     561
     562        // update_option() can return false for either an unchanged value or a failed write.
     563        // The strict read-back is the authoritative persistence check.
     564        return get_option($option_name, '') === $version;
     565    }
     566
     567    /** Create the durable, privacy-safe summary ledger for historical import outcomes. */
     568    private function maybe_upgrade_historical_import_report_schema() {
     569        global $wpdb;
     570        if (get_option('statpl_history_report_schema_version', '') === self::HISTORY_REPORT_SCHEMA_VERSION) {
     571            return true;
     572        }
     573        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     574        $collate = $wpdb->get_charset_collate();
     575        dbDelta("CREATE TABLE {$this->historical_import_report_table} (
     576            id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     577            source_key VARCHAR(100) NOT NULL,
     578            source_name VARCHAR(191) NOT NULL,
     579            origin_key VARCHAR(100) NOT NULL DEFAULT '',
     580            source_record_hash CHAR(64) NOT NULL,
     581            outcome VARCHAR(20) NOT NULL DEFAULT 'failed',
     582            attempts INT(10) UNSIGNED NOT NULL DEFAULT 0,
     583            reason_code VARCHAR(100) NOT NULL DEFAULT '',
     584            updated_at DATETIME NOT NULL,
     585            PRIMARY KEY  (id),
     586            UNIQUE KEY source_record (source_key, source_record_hash),
     587            KEY source_outcome (source_key, outcome),
     588            KEY outcome (outcome)
     589        ) $collate;");
     590        $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($this->historical_import_report_table)));
     591        if ($exists !== $this->historical_import_report_table || !$this->verify_historical_table_columns($this->historical_import_report_table, ['id','source_key','source_name','origin_key','source_record_hash','outcome','attempts','reason_code','updated_at'])) {
     592            return false;
     593        }
     594        // Preserve visibility of records imported before this report ledger existed.
     595        statpl_db_query("INSERT IGNORE INTO {$this->historical_import_report_table}
     596            (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at)
     597            SELECT s.source_key, s.display_name, '', e.source_record_hash, 'imported', 0, '', e.updated_at
     598            FROM {$this->view_source_events_table} e
     599            INNER JOIN {$this->view_sources_table} s ON s.id = e.source_id");
     600        if (!empty($wpdb->last_error)) { return false; }
     601        statpl_db_query("INSERT IGNORE INTO {$this->historical_import_report_table}
     602            (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at)
     603            SELECT s.source_key, s.display_name, f.origin_key, f.source_record_hash, IF(f.status = 'retry', 'retry', 'failed'), f.attempts, 'record_import_failed', f.updated_at
     604            FROM {$this->view_source_failures_table} f
     605            INNER JOIN {$this->view_sources_table} s ON s.id = f.source_id");
     606        if (!empty($wpdb->last_error)) { return false; }
     607        statpl_db_query("INSERT IGNORE INTO {$this->historical_import_report_table}
     608            (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at)
     609            SELECT 'slimstat', 'SlimStat', 'slimstat', SHA2(CONCAT('slimstat', CHAR(0), e.source_view_id), 256), 'imported', 0, '', e.imported_at
     610            FROM {$this->view_import_events_table} e WHERE e.source = 'slimstat' AND e.counts_applied = 1");
     611        if (!empty($wpdb->last_error)) { return false; }
     612        statpl_db_query("INSERT IGNORE INTO {$this->historical_import_report_table}
     613            (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at)
     614            SELECT 'slimstat', 'SlimStat', 'slimstat', SHA2(CONCAT('slimstat', CHAR(0), f.source_view_id), 256), IF(f.status = 'retry', 'retry', 'failed'), f.attempts, 'record_import_failed', f.updated_at
     615            FROM {$this->view_import_failures_table} f WHERE f.source = 'slimstat'");
     616        if (!empty($wpdb->last_error)) { return false; }
     617        return $this->persist_schema_version_marker('statpl_history_report_schema_version', self::HISTORY_REPORT_SCHEMA_VERSION);
     618    }
     619
     620    /** Save one eligible historical record's latest durable outcome; no visitor identifiers are stored. */
     621    private function save_historical_import_report_record($source_key, $source_name, $origin_key, $record_hash, $outcome, $attempts = 0, $reason_code = '') {
     622        global $wpdb;
     623        $source_key = sanitize_key((string) $source_key);
     624        $source_name = sanitize_text_field((string) $source_name);
     625        $origin_key = sanitize_key((string) $origin_key);
     626        $record_hash = strtolower((string) $record_hash);
     627        if ($source_key === '' || $source_name === '' || !preg_match('/^[a-f0-9]{64}$/D', $record_hash)
     628            || !in_array($outcome, ['imported','retry','failed'], true)) {
     629            return false;
     630        }
     631        $result = statpl_db_query(statpl_db_prepare(
     632            "INSERT INTO {$this->historical_import_report_table}
     633                (source_key, source_name, origin_key, source_record_hash, outcome, attempts, reason_code, updated_at)
     634             VALUES (%s, %s, %s, %s, %s, %d, %s, %s)
     635             ON DUPLICATE KEY UPDATE
     636                source_name = VALUES(source_name),
     637                outcome = VALUES(outcome),
     638                attempts = GREATEST(attempts, VALUES(attempts)),
     639                reason_code = VALUES(reason_code),
     640                updated_at = VALUES(updated_at)",
     641            $source_key, $source_name, $origin_key, $record_hash, $outcome,
     642            max(0, (int) $attempts), sanitize_key((string) $reason_code), current_time('mysql')
     643        ));
     644        return $result !== false && empty($wpdb->last_error);
     645    }
     646
     647    /** Resolve a generic source's stable public name for the administrator report. */
     648    private function save_generic_historical_report_record($source_id, $origin_key, $record_hash, $outcome, $attempts = 0, $reason_code = '') {
     649        global $wpdb;
     650        $source = statpl_db_get_row(statpl_db_prepare(
     651            "SELECT source_key, display_name FROM {$this->view_sources_table} WHERE id = %d LIMIT 1",
     652            absint($source_id)
     653        ), ARRAY_A);
     654        if (!is_array($source) || !empty($wpdb->last_error)) {
     655            return false;
     656        }
     657        return $this->save_historical_import_report_record(
     658            (string) $source['source_key'], (string) $source['display_name'],
     659            (string) $origin_key, (string) $record_hash, (string) $outcome,
     660            (int) $attempts, (string) $reason_code
     661        );
     662    }
     663
     664    /** Aggregate historical import state without exposing source errors or personal data. */
     665    private function get_historical_import_report_summary() {
     666        global $wpdb;
     667        $rows = statpl_db_get_results(
     668            "SELECT source_key, source_name, COUNT(*) AS eligible,
     669                    SUM(CASE WHEN outcome = 'imported' THEN 1 ELSE 0 END) AS imported,
     670                    SUM(CASE WHEN outcome IN ('retry','failed') THEN 1 ELSE 0 END) AS not_imported,
     671                    SUM(CASE WHEN outcome = 'retry' THEN 1 ELSE 0 END) AS pending_retries,
     672                    SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END) AS failed,
     673                    SUM(CASE WHEN outcome = 'imported' AND attempts > 0 THEN 1 ELSE 0 END) AS retry_resolved,
     674                    SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END) AS final_failures
     675             FROM {$this->historical_import_report_table}
     676             GROUP BY source_key, source_name ORDER BY source_name ASC",
     677            ARRAY_A
     678        );
     679        if (!empty($wpdb->last_error) || !is_array($rows)) {
     680            return ['available' => false, 'sources' => [], 'eligible' => 0, 'imported' => 0, 'not_imported' => 0, 'pending_retries' => 0, 'failed' => 0, 'retry_resolved' => 0];
     681        }
     682        $summary = ['available' => true, 'sources' => $rows, 'eligible' => 0, 'imported' => 0, 'not_imported' => 0, 'pending_retries' => 0, 'failed' => 0, 'retry_resolved' => 0];
     683        foreach ($rows as $row) {
     684            foreach (['eligible','imported','not_imported','pending_retries','failed','retry_resolved'] as $key) {
     685                $summary[$key] += (int) ($row[$key] ?? 0);
     686            }
     687        }
     688        return $summary;
     689    }
     690
     691    /** Determine the user-facing historical-import state from persisted source and record outcomes. */
     692    private function get_historical_import_lifecycle_state() {
     693        global $wpdb;
     694        $report = $this->get_historical_import_report_summary();
     695        if (empty($report['available'])) {
     696            return ['state' => 'technical', 'report' => $report];
     697        }
     698        $slimstat = statpl_db_get_row(statpl_db_prepare(
     699            "SELECT status FROM {$this->view_import_state_table} WHERE source = %s LIMIT 1", 'slimstat'
     700        ), ARRAY_A);
     701        $sources = statpl_db_get_results("SELECT status FROM {$this->view_sources_table}", ARRAY_A);
     702        if (!empty($wpdb->last_error) || !is_array($sources)) {
     703            return ['state' => 'importing', 'report' => $report];
     704        }
     705        $probe = (string) get_option('statpl_history_slimstat_probe', 'not_checked');
     706        $discovery = (string) get_option('statpl_history_discovery_status', 'not_checked');
     707        $slimstat_pending = is_array($slimstat) && in_array((string) ($slimstat['status'] ?? ''), ['pending','running'], true);
     708        $technical = in_array($probe, ['missing_tables','incompatible_schema','database_error'], true)
     709            || ($slimstat_pending && $probe === 'not_installed')
     710            || in_array($discovery, ['provider_error','invalid_registry','lock_unavailable','lock_session_lost'], true);
     711        if (in_array($discovery, ['busy','not_checked','unknown'], true)) {
     712            return ['state' => 'importing', 'report' => $report];
     713        }
     714        $has_source = is_array($slimstat) || !empty($sources);
     715        $active = false;
     716        foreach ($sources as $source) {
     717            if (in_array((string) ($source['status'] ?? ''), ['detected','importing','retry_wait'], true)) {
     718                $active = true;
     719            }
     720            if (in_array((string) ($source['status'] ?? ''), ['needs_attention','partial'], true)) {
     721                $technical = true;
     722            }
     723        }
     724        if ($slimstat_pending && !$technical) {
     725            $active = true;
     726        }
     727        $unavailable_active_origins = statpl_db_get_var(
     728            "SELECT COUNT(*) FROM {$this->view_source_origins_table} WHERE detection_status = 'available' AND status = 'unavailable'"
     729        );
     730        if (empty($wpdb->last_error) && (int) $unavailable_active_origins > 0) {
     731            $technical = true;
     732        }
     733        if ($active || (int) ($report['pending_retries'] ?? 0) > 0) {
     734            return ['state' => 'importing', 'report' => $report];
     735        }
     736        if (!$has_source) {
     737            return ['state' => $technical ? 'technical' : 'none', 'report' => $report];
     738        }
     739        if ((int) $report['failed'] > 0) {
     740            return ['state' => ((int) $report['imported'] > 0) ? 'partial' : 'technical', 'report' => $report];
     741        }
     742        if ($technical) {
     743            return ['state' => ((int) $report['imported'] > 0) ? 'partial' : 'technical', 'report' => $report];
     744        }
     745        if ((int) $report['eligible'] === 0) {
     746            return ['state' => 'none', 'report' => $report];
     747        }
     748        return ['state' => 'success', 'report' => $report];
     749    }
     750
     751    /** Render the same concise historical-import notice above both dashboard views. */
     752    private function render_historical_import_notice() {
     753        $lifecycle = $this->get_historical_import_lifecycle_state();
     754        $state = (string) ($lifecycle['state'] ?? 'importing');
     755
     756        // The in-progress notice remains visible while work is active. A terminal
     757        // notice is presented only once for the lifetime of the current Statora
     758        // data set; the marker is a Statora-owned option and is removed by the
     759        // explicit data-deletion path. Retention/reactivation preserves it.
     760        if ($state !== 'importing') {
     761            $notice_marker = 'statpl_history_final_notice_shown';
     762            if (get_option($notice_marker, '') !== '') {
     763                return;
     764            }
     765            // add_option() is atomic for a unique option_name. Only the request
     766            // that successfully creates the marker may render the final notice.
     767            if (!add_option($notice_marker, $state, '', false)) {
     768                return;
     769            }
     770            // Start the short report-access window when the terminal notice is
     771            // first rendered, not when the administrator later opens the report.
     772            if (in_array($state, ['partial','technical'], true)) {
     773                update_option('statpl_history_final_notice_at', time(), false);
     774            }
     775        }
     776
     777        $report_url = admin_url('admin.php?page=statpl-history-report');
     778        $messages = [
     779            'importing' => ['title' => 'Επεξεργασία ιστορικού', 'body' => 'Η αρχική εισαγωγή των ιστορικών προβολών προϊόντων βρίσκεται σε εξέλιξη. Τα στατιστικά ενδέχεται να μην είναι ακόμη πλήρη μέχρι να ολοκληρωθεί η διαδικασία.'],
     780            'success' => ['title' => 'Το Statora Plus είναι έτοιμο!', 'body' => 'Η εισαγωγή των προηγούμενων καταγεγραμμένων προβολών για τα προϊόντα του καταστήματός σας ολοκληρώθηκε επιτυχώς.'],
     781            'partial' => ['title' => 'Το Statora Plus είναι έτοιμο!', 'body' => 'Η εισαγωγή των προηγούμενων καταγεγραμμένων προβολών για τα προϊόντα του καταστήματός σας ολοκληρώθηκε μερικώς.'],
     782            'none' => ['title' => 'Το Statora Plus είναι έτοιμο!', 'body' => 'Δεν βρέθηκαν διαθέσιμες προηγούμενες καταγεγραμμένες προβολές προϊόντων για εισαγωγή.'],
     783            'technical' => ['title' => 'Το Statora Plus είναι έτοιμο!', 'body' => 'Δεν ήταν δυνατή η εισαγωγή των προηγούμενων καταγεγραμμένων προβολών των προϊόντων σας λόγω τεχνικού προβλήματος.'],
     784        ];
     785        $message = $messages[$state] ?? $messages['importing'];
     786        echo '<div class="statpl-history-notice statpl-history-notice-' . esc_attr($state) . '" role="status" aria-live="polite">';
     787        echo '<button type="button" class="statpl-history-notice-close" aria-label="Κλείσιμο ειδοποίησης">&times;</button>';
     788        if ($state === 'importing') { echo '<span class="statpl-history-notice-icon dashicons dashicons-info-outline" aria-hidden="true"></span>'; }
     789        echo '<strong>' . esc_html($message['title']) . '</strong>';
     790        echo '<p>' . esc_html($message['body']) . '</p>';
     791        if ($state !== 'importing') {
     792            if ($state === 'none') {
     793                echo '<p>Το Statora Plus ξεκινά την καταγραφή των προβολών των προϊόντων σας από τώρα και λειτουργεί αυτόνομα.</p>';
     794            } elseif ($state === 'success') {
     795                echo '<p>Από εδώ και στο εξής, το Statora Plus καταγράφει αυτόνομα τις νέες προβολές των προϊόντων σας.</p>';
     796            } else {
     797                echo '<p>Το Statora Plus συνεχίζει να καταγράφει αυτόνομα τις νέες προβολές των προϊόντων σας.</p>';
     798            }
     799            echo '<p><strong>Δεν απαιτείται κάποια άλλη ενέργεια εκ μέρους σας.</strong></p>';
     800        }
     801        if (in_array($state, ['partial','technical'], true)) {
     802            echo '<a class="statpl-history-report-link" href="' . esc_url($report_url) . '">Περισσότερες πληροφορίες →</a>';
     803        }
     804        echo '</div>';
     805    }
     806
     807    /** Settings page with an administrator-friendly, privacy-safe import summary. */
     808    public function render_historical_import_report_page() {
     809        if (!current_user_can('manage_woocommerce')) {
     810            wp_die(esc_html__('Δεν έχεις δικαίωμα προβολής αυτής της σελίδας.', 'statora-plus'));
     811        }
     812        $notice_state = (string) get_option('statpl_history_final_notice_shown', '');
     813        $notice_time = absint(get_option('statpl_history_final_notice_at', 0));
     814        $report_window_seconds = 15 * MINUTE_IN_SECONDS;
     815        $report_is_available = in_array($notice_state, ['partial','technical'], true)
     816            && $notice_time > 0
     817            && time() >= $notice_time
     818            && (time() - $notice_time) < $report_window_seconds;
     819        if (!$report_is_available) {
     820            wp_safe_redirect(admin_url('admin.php?page=statpl-product-metrics'));
     821            exit;
     822        }
     823        $report = $this->get_historical_import_report_summary();
     824        $lifecycle = $this->get_historical_import_lifecycle_state();
     825        echo '<div class="wrap"><h1>Αρχική εγκατάσταση και ιστορικό προβολών</h1>';
     826        if (($lifecycle['state'] ?? '') === 'technical') {
     827            echo '<p>Δεν ήταν δυνατή η ολοκλήρωση της ανάγνωσης μίας διαθέσιμης πηγής ιστορικών προβολών. Το Statora Plus συνεχίζει αυτόνομα την καταγραφή νέων προβολών προϊόντων.</p>';
     828            $slimstat_probe = (string) get_option('statpl_history_slimstat_probe', '');
     829            if (in_array($slimstat_probe, ['missing_tables','incompatible_schema','database_error'], true)) {
     830                echo '<p><strong>SlimStat:</strong> Δεν ήταν δυνατή η ανάγνωση των ιστορικών δεδομένων του.</p>';
     831            }
     832        } elseif (($lifecycle['state'] ?? '') === 'partial') {
     833            echo '<p>Η εισαγωγή ολοκληρώθηκε με μερικό αποτέλεσμα. Οι εγγραφές που εισήχθησαν επιτυχώς διατηρούνται και η αυτόνομη καταγραφή συνεχίζεται.</p>';
     834        }
     835        if (empty($report['available'])) {
     836            echo '<p>Η αναλυτική αναφορά δεν είναι προσωρινά διαθέσιμη. Τα στατιστικά του καταστήματος δεν επηρεάζονται.</p></div>';
     837            return;
     838        }
     839        echo '<p>Συγκεντρωτικά αποτελέσματα εισαγωγής ιστορικών προβολών προϊόντων.</p>';
     840        echo '<table class="widefat striped"><thead><tr><th>Πηγή</th><th>Επιλέξιμες εγγραφές</th><th>Εισήχθησαν</th><th>Δεν εισήχθησαν</th><th>Σε επαναπροσπάθεια</th><th>Επιλύθηκαν με επαναπροσπάθεια</th><th>Οριστικές αποτυχίες</th><th>Αιτία αποτυχίας</th></tr></thead><tbody>';
     841        if (empty($report['sources'])) {
     842            echo '<tr><td colspan="8">Δεν υπάρχουν διαθέσιμα ιστορικά δεδομένα προς αναφορά.</td></tr>';
     843        } else {
     844            foreach ($report['sources'] as $row) {
     845                echo '<tr><td>' . esc_html((string) $row['source_name']) . '</td><td>' . esc_html((string) (int) $row['eligible']) . '</td><td>' . esc_html((string) (int) $row['imported']) . '</td><td>' . esc_html((string) (int) $row['not_imported']) . '</td><td>' . esc_html((string) (int) $row['pending_retries']) . '</td><td>' . esc_html((string) (int) $row['retry_resolved']) . '</td><td>' . esc_html((string) (int) $row['final_failures']) . '</td><td>' . ((int) $row['failed'] > 0 ? 'Ορισμένες εγγραφές δεν μπόρεσαν να καταγραφούν μετά από επαναπροσπάθειες.' : '—') . '</td></tr>';
     846            }
     847        }
     848        echo '</tbody></table>';
     849        if ((int) $report['failed'] > 0) {
     850            echo '<h2>Τι σημαίνουν τα αποτελέσματα</h2><p>Ορισμένες ιστορικές εγγραφές δεν ήταν δυνατό να εισαχθούν. Οι επιτυχημένες εισαγωγές διατηρούνται και το Statora Plus συνεχίζει κανονικά την αυτόνομη καταγραφή νέων προβολών.</p>';
     851        }
     852        echo '<p>Η αναφορά περιέχει μόνο συγκεντρωτικά αποτελέσματα. Δεν αποθηκεύονται ή εμφανίζονται προσωπικά στοιχεία επισκεπτών.</p></div>';
     853    }
     854
     855    /** Verify every required column before persisting a schema-version marker. */
     856    private function verify_historical_table_columns($table, $required_columns) {
     857        global $wpdb;
     858
     859        if (!is_object($wpdb) || !isset($wpdb->last_error) || !is_string($table) || !is_array($required_columns)) {
     860            return false;
     861        }
     862
     863        // Only Statora-owned tables may be inspected by this helper. Validate the
     864        // complete identifier here rather than relying on each caller to do so.
     865        $allowed_tables = [
     866            $this->views_table,
     867            $this->events_table,
     868            $this->order_events_table,
     869            $this->order_items_table,
     870            $this->view_imports_table,
     871            $this->view_import_state_table,
     872            $this->view_import_events_table,
     873            $this->view_import_failures_table,
     874            $this->view_sources_table,
     875            $this->view_source_origins_table,
     876            $this->view_source_state_table,
     877            $this->view_source_events_table,
     878            $this->view_source_failures_table,
     879            $this->view_source_reconciliation_table,
     880            $this->view_source_reconciliation_history_table,
     881            $this->historical_import_report_table,
     882        ];
     883        if (!in_array($table, $allowed_tables, true) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     884            return false;
     885        }
     886        foreach ($required_columns as $column) {
     887            if (!is_string($column) || !preg_match('/^[A-Za-z0-9_]+$/D', $column)) {
     888                return false;
     889            }
     890        }
     891
     892        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is restricted to the explicit Statora-owned table allowlist and quoted as one identifier.
     893        $rows = statpl_db_get_results("SHOW COLUMNS FROM `{$table}`", ARRAY_A);
     894        if ($wpdb->last_error !== '' || !is_array($rows)) {
     895            return false;
     896        }
     897
     898        $found = [];
     899        foreach ($rows as $row) {
     900            if (isset($row['Field'])) {
     901                $found[$row['Field']] = true;
     902            }
     903        }
     904
     905        foreach ($required_columns as $column) {
     906            if (!isset($found[$column])) {
     907                return false;
     908            }
     909        }
     910
     911        return true;
     912    }
     913
     914    private function maybe_upgrade_view_import_schema() {
     915    global $wpdb;
     916
     917    $schema_version = get_option('statpl_view_import_schema_version', '');
     918
     919    if ($schema_version === self::VIEW_IMPORT_SCHEMA_VERSION) {
     920        return;
     921    }
     922
     923    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     924
     925    $collate = $wpdb->get_charset_collate();
     926
     927    dbDelta("CREATE TABLE {$this->view_imports_table} (
     928        id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     929        source VARCHAR(50) NOT NULL,
     930        product_id BIGINT(20) UNSIGNED NOT NULL,
     931        imported_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     932        imported_at DATETIME NOT NULL,
     933        PRIMARY KEY  (id),
     934        UNIQUE KEY source_product (source, product_id),
     935        KEY imported_at (imported_at)
     936    ) $collate;");
     937
     938    $table_exists = statpl_db_get_var(
     939        statpl_db_prepare(
     940            'SHOW TABLES LIKE %s',
     941            $wpdb->esc_like($this->view_imports_table)
     942        )
     943    );
     944
     945    if ($table_exists === $this->view_imports_table && $this->verify_historical_table_columns($this->view_imports_table, ['id', 'source', 'product_id', 'imported_views', 'imported_at'])) {
     946        if (!$this->persist_schema_version_marker('statpl_view_import_schema_version', self::VIEW_IMPORT_SCHEMA_VERSION)) { return false; }
     947    }
     948}
     949private function maybe_upgrade_view_import_state_schema() {
     950    global $wpdb;
     951
     952    $schema_version = get_option('statpl_view_import_state_schema_version', '');
     953
     954    if ($schema_version === self::VIEW_IMPORT_STATE_SCHEMA_VERSION) {
     955        return;
     956    }
     957
     958    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     959
     960    $collate = $wpdb->get_charset_collate();
     961
     962    dbDelta("CREATE TABLE {$this->view_import_state_table} (
     963        source VARCHAR(50) NOT NULL,
     964            snapshot_max_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     965            snapshot_active_max_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     966            snapshot_archive_max_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     967            last_processed_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     968            status VARCHAR(20) NOT NULL DEFAULT 'pending',
     969            updated_at DATETIME NOT NULL,
     970            PRIMARY KEY  (source)
     971    ) $collate;");
     972
     973    $table_exists = statpl_db_get_var(
     974        statpl_db_prepare(
     975            'SHOW TABLES LIKE %s',
     976            $wpdb->esc_like($this->view_import_state_table)
     977        )
     978    );
     979
     980    if ($table_exists === $this->view_import_state_table && $this->verify_historical_table_columns($this->view_import_state_table, ['source', 'snapshot_max_id', 'snapshot_active_max_id', 'snapshot_archive_max_id', 'last_processed_id', 'status', 'updated_at'])) {
     981        if (!$this->persist_schema_version_marker('statpl_view_import_state_schema_version', self::VIEW_IMPORT_STATE_SCHEMA_VERSION)) { return false; }
     982    }
     983}
     984
     985private function maybe_upgrade_view_import_events_schema() {
     986    global $wpdb;
     987
     988    $schema_version = get_option('statpl_view_import_events_schema_version', '');
     989
     990    if ($schema_version === self::VIEW_IMPORT_EVENTS_SCHEMA_VERSION) {
     991        return;
     992    }
     993
     994    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     995
     996    $collate = $wpdb->get_charset_collate();
     997
     998    dbDelta("CREATE TABLE {$this->view_import_events_table} (
     999        id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     1000            source VARCHAR(50) NOT NULL,
     1001            source_view_id BIGINT(20) UNSIGNED NOT NULL,
     1002            product_id BIGINT(20) UNSIGNED NOT NULL,
     1003            imported_at DATETIME NOT NULL,
     1004            counts_applied TINYINT(1) UNSIGNED NOT NULL DEFAULT 1,
     1005            PRIMARY KEY  (id),
     1006            UNIQUE KEY source_view (source, source_view_id),
     1007            KEY product_id (product_id)
     1008    ) $collate;");
     1009
     1010    $table_exists = statpl_db_get_var(
     1011        statpl_db_prepare(
     1012            'SHOW TABLES LIKE %s',
     1013            $wpdb->esc_like($this->view_import_events_table)
     1014        )
     1015    );
     1016
     1017    $column_exists = false;
     1018
     1019    if ($table_exists === $this->view_import_events_table) {
     1020        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The table identifier is built from the WordPress prefix and a fixed plugin suffix.
     1021        $column_exists = statpl_db_get_var(
     1022            statpl_db_prepare(
     1023                "SHOW COLUMNS FROM {$this->view_import_events_table} LIKE %s",
     1024                'counts_applied'
     1025            )
     1026        );
     1027    }
     1028
     1029    if (
     1030        $table_exists === $this->view_import_events_table
     1031        && 'counts_applied' === $column_exists
     1032        && $this->verify_historical_table_columns($this->view_import_events_table, ['id', 'source', 'source_view_id', 'product_id', 'imported_at', 'counts_applied'])
     1033    ) {
     1034        if (!$this->persist_schema_version_marker('statpl_view_import_events_schema_version', self::VIEW_IMPORT_EVENTS_SCHEMA_VERSION)) { return false; }
     1035    }
     1036}
     1037
     1038private function maybe_upgrade_view_import_failures_schema() {
     1039    global $wpdb;
     1040
     1041    $schema_version = get_option('statpl_view_import_failures_schema_version', '');
     1042
     1043    if ($schema_version === self::VIEW_IMPORT_FAILURES_SCHEMA_VERSION) {
     1044        return;
     1045    }
     1046
     1047    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     1048
     1049    $collate = $wpdb->get_charset_collate();
     1050
     1051    dbDelta("CREATE TABLE {$this->view_import_failures_table} (
     1052        id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     1053        source VARCHAR(50) NOT NULL,
     1054        source_view_id BIGINT(20) UNSIGNED NOT NULL,
     1055        product_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     1056        attempts INT(10) UNSIGNED NOT NULL DEFAULT 0,
     1057        last_error TEXT NOT NULL,
     1058        status VARCHAR(20) NOT NULL DEFAULT 'retry',
     1059        updated_at DATETIME NOT NULL,
     1060        PRIMARY KEY  (id),
     1061        UNIQUE KEY source_view (source, source_view_id),
     1062        KEY status (status)
     1063    ) $collate;");
     1064
     1065    $table_exists = statpl_db_get_var(
     1066        statpl_db_prepare(
     1067            'SHOW TABLES LIKE %s',
     1068            $wpdb->esc_like($this->view_import_failures_table)
     1069        )
     1070    );
     1071
     1072    if ($table_exists === $this->view_import_failures_table && $this->verify_historical_table_columns($this->view_import_failures_table, ['id', 'source', 'source_view_id', 'product_id', 'attempts', 'last_error', 'status', 'updated_at'])) {
     1073        if (!$this->persist_schema_version_marker('statpl_view_import_failures_schema_version', self::VIEW_IMPORT_FAILURES_SCHEMA_VERSION)) { return false; }
     1074    }
     1075}
     1076
     1077private function maybe_upgrade_view_source_schema() {
     1078    global $wpdb;
     1079
     1080    $schema_version = self::VIEW_SOURCE_SCHEMA_VERSION;
     1081    if (get_option('statpl_view_source_schema_version', '') === $schema_version) {
     1082        return true;
     1083    }
     1084
     1085    $collate = $wpdb->get_charset_collate();
     1086
     1087    $tables = [
     1088        $this->view_sources_table,
     1089        $this->view_source_origins_table,
     1090        $this->view_source_state_table,
     1091        $this->view_source_events_table,
     1092        $this->view_source_failures_table,
     1093        $this->view_source_reconciliation_table,
     1094        $this->view_source_reconciliation_history_table,
     1095    ];
     1096
     1097    $definitions = [
     1098        $this->view_sources_table => [
     1099            'columns' => [
     1100                'id' => ['bigint(20) unsigned', 'NO', null, true],
     1101                'source_key' => ['varchar(100)', 'NO', null, false],
     1102                'adapter_key' => ['varchar(100)', 'YES', null, false],
     1103                'display_name' => ['varchar(191)', 'NO', null, false],
     1104                'adapter_version' => ['varchar(32)', 'NO', null, false],
     1105                'granularity' => ['varchar(20)', 'NO', null, false],
     1106                'status' => ['varchar(20)', 'NO', 'detected', false],
     1107                'created_at' => ['datetime', 'NO', null, false],
     1108                'updated_at' => ['datetime', 'NO', null, false],
     1109            ],
     1110            'indexes' => [
     1111                'PRIMARY' => [true, ['id']],
     1112                'source_key' => [true, ['source_key']],
     1113                'status' => [false, ['status']],
     1114            ],
     1115        ],
     1116        $this->view_source_origins_table => [
     1117            'columns' => [
     1118                'source_id' => ['bigint(20) unsigned', 'NO', null, false],
     1119                'origin_key' => ['varchar(100)', 'NO', null, false],
     1120                'adapter_key' => ['varchar(100)', 'NO', null, false],
     1121                'capabilities' => ['longtext', 'YES', null, false],
     1122                'snapshot_data' => ['longtext', 'YES', null, false],
     1123                'checkpoint_data' => ['longtext', 'YES', null, false],
     1124                'detection_status' => ['varchar(20)', 'NO', 'unknown', false],
     1125                'status' => ['varchar(20)', 'NO', 'pending', false],
     1126                'last_error' => ['text', 'YES', null, false],
     1127                'started_at' => ['datetime', 'YES', null, false],
     1128                'completed_at' => ['datetime', 'YES', null, false],
     1129                'updated_at' => ['datetime', 'NO', null, false],
     1130            ],
     1131            'indexes' => [
     1132                'PRIMARY' => [true, ['source_id', 'origin_key']],
     1133                'status' => [false, ['status']],
     1134            ],
     1135        ],
     1136        $this->view_source_state_table => [
     1137            'columns' => [
     1138                'source_id' => ['bigint(20) unsigned', 'NO', null, false],
     1139                'snapshot_data' => ['longtext', 'YES', null, false],
     1140                'checkpoint_data' => ['longtext', 'YES', null, false],
     1141                'status' => ['varchar(20)', 'NO', 'pending', false],
     1142                'last_error' => ['text', 'YES', null, false],
     1143                'started_at' => ['datetime', 'YES', null, false],
     1144                'completed_at' => ['datetime', 'YES', null, false],
     1145                'updated_at' => ['datetime', 'NO', null, false],
     1146            ],
     1147            'indexes' => [
     1148                'PRIMARY' => [true, ['source_id']],
     1149                'status' => [false, ['status']],
     1150            ],
     1151        ],
     1152        $this->view_source_events_table => [
     1153            'columns' => [
     1154                'id' => ['bigint(20) unsigned', 'NO', null, true],
     1155                'source_id' => ['bigint(20) unsigned', 'NO', null, false],
     1156                'source_record_key' => ['varchar(191)', 'NO', null, false],
     1157                'source_record_hash' => ['char(64)', 'NO', null, false],
     1158                'product_id' => ['bigint(20) unsigned', 'NO', null, false],
     1159                'record_type' => ['varchar(20)', 'NO', 'event', false],
     1160                'period_start' => ['datetime', 'YES', null, false],
     1161                'period_end' => ['datetime', 'YES', null, false],
     1162                'observed_views' => ['bigint(20) unsigned', 'NO', '0', false],
     1163                'applied_views' => ['bigint(20) unsigned', 'NO', '0', false],
     1164                'source_fingerprint' => ['char(64)', 'YES', null, false],
     1165                'status' => ['varchar(20)', 'NO', 'pending', false],
     1166                'created_at' => ['datetime', 'NO', null, false],
     1167                'updated_at' => ['datetime', 'NO', null, false],
     1168            ],
     1169            'indexes' => [
     1170                'PRIMARY' => [true, ['id']],
     1171                'source_record' => [true, ['source_id', 'source_record_hash']],
     1172                'product_id' => [false, ['product_id']],
     1173                'source_product' => [false, ['source_id', 'product_id']],
     1174                'source_fingerprint' => [false, ['source_fingerprint']],
     1175                'product_record_period' => [false, ['product_id', 'record_type', 'period_start', 'period_end']],
     1176                'status' => [false, ['status']],
     1177            ],
     1178        ],
     1179        $this->view_source_reconciliation_table => [
     1180            'columns' => [
     1181                'id' => ['bigint(20) unsigned', 'NO', null, true],
     1182                'event_id' => ['bigint(20) unsigned', 'NO', null, false],
     1183                'status' => ['varchar(30)', 'NO', 'unprocessed', false],
     1184                'reason_code' => ['varchar(100)', 'NO', null, false],
     1185                'reason' => ['text', 'YES', null, false],
     1186                'matched_event_id' => ['bigint(20) unsigned', 'YES', null, false],
     1187                'evidence' => ['longtext', 'YES', null, false],
     1188                'algorithm_version' => ['varchar(32)', 'NO', null, false],
     1189                'created_at' => ['datetime', 'NO', null, false],
     1190                'updated_at' => ['datetime', 'NO', null, false],
     1191            ],
     1192            'indexes' => [
     1193                'PRIMARY' => [true, ['id']],
     1194                'event_id' => [true, ['event_id']],
     1195                'status' => [false, ['status']],
     1196                'matched_event_id' => [false, ['matched_event_id']],
     1197            ],
     1198        ],
     1199        $this->view_source_reconciliation_history_table => [
     1200            'columns' => [
     1201                'id' => ['bigint(20) unsigned', 'NO', null, true],
     1202                'event_id' => ['bigint(20) unsigned', 'NO', null, false],
     1203                'previous_status' => ['varchar(30)', 'YES', null, false],
     1204                'new_status' => ['varchar(30)', 'NO', null, false],
     1205                'reason_code' => ['varchar(100)', 'NO', null, false],
     1206                'reason' => ['text', 'YES', null, false],
     1207                'matched_event_id' => ['bigint(20) unsigned', 'YES', null, false],
     1208                'evidence' => ['longtext', 'YES', null, false],
     1209                'algorithm_version' => ['varchar(32)', 'NO', null, false],
     1210                'created_at' => ['datetime', 'NO', null, false],
     1211            ],
     1212            'indexes' => [
     1213                'PRIMARY' => [true, ['id']],
     1214                'event_id' => [false, ['event_id']],
     1215                'created_at' => [false, ['created_at']],
     1216            ],
     1217        ],
     1218        $this->view_source_failures_table => [
     1219            'columns' => [
     1220                'id' => ['bigint(20) unsigned', 'NO', null, true],
     1221                'source_id' => ['bigint(20) unsigned', 'NO', null, false],
     1222                'origin_key' => ['varchar(100)', 'NO', null, false],
     1223                'source_record_key' => ['varchar(191)', 'NO', null, false],
     1224                'source_record_hash' => ['char(64)', 'NO', null, false],
     1225                'product_id' => ['bigint(20) unsigned', 'NO', '0', false],
     1226                'attempts' => ['int(10) unsigned', 'NO', '0', false],
     1227                'last_error' => ['text', 'NO', null, false],
     1228                'status' => ['varchar(20)', 'NO', 'retry', false],
     1229                'next_retry_at' => ['datetime', 'YES', null, false],
     1230                'retry_checkpoint' => ['longtext', 'YES', null, false],
     1231                'updated_at' => ['datetime', 'NO', null, false],
     1232            ],
     1233            'indexes' => [
     1234                'PRIMARY' => [true, ['id']],
     1235                'source_record' => [true, ['source_id', 'source_record_hash']],
     1236                'status_retry' => [false, ['status', 'next_retry_at']],
     1237                'source_origin' => [false, ['source_id', 'origin_key']],
     1238                'product_id' => [false, ['product_id']],
     1239            ],
     1240        ],
     1241    ];
     1242
     1243    $sql = [
     1244        "CREATE TABLE {$this->view_sources_table} (
     1245            id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     1246            source_key VARCHAR(100) NOT NULL,
     1247            adapter_key VARCHAR(100) NULL DEFAULT NULL,
     1248            display_name VARCHAR(191) NOT NULL,
     1249            adapter_version VARCHAR(32) NOT NULL,
     1250            granularity VARCHAR(20) NOT NULL,
     1251            status VARCHAR(20) NOT NULL DEFAULT 'detected',
     1252            created_at DATETIME NOT NULL,
     1253            updated_at DATETIME NOT NULL,
     1254            PRIMARY KEY  (id),
     1255            UNIQUE KEY source_key (source_key),
     1256            KEY status (status)
     1257        ) ENGINE=InnoDB $collate;",
     1258        "CREATE TABLE {$this->view_source_origins_table} (
     1259            source_id BIGINT(20) UNSIGNED NOT NULL,
     1260            origin_key VARCHAR(100) NOT NULL,
     1261            adapter_key VARCHAR(100) NOT NULL,
     1262            capabilities LONGTEXT NULL DEFAULT NULL,
     1263            snapshot_data LONGTEXT NULL DEFAULT NULL,
     1264            checkpoint_data LONGTEXT NULL DEFAULT NULL,
     1265            detection_status VARCHAR(20) NOT NULL DEFAULT 'unknown',
     1266            status VARCHAR(20) NOT NULL DEFAULT 'pending',
     1267            last_error TEXT NULL DEFAULT NULL,
     1268            started_at DATETIME NULL DEFAULT NULL,
     1269            completed_at DATETIME NULL DEFAULT NULL,
     1270            updated_at DATETIME NOT NULL,
     1271            PRIMARY KEY  (source_id, origin_key),
     1272            KEY status (status)
     1273        ) ENGINE=InnoDB $collate;",
     1274        "CREATE TABLE {$this->view_source_state_table} (
     1275            source_id BIGINT(20) UNSIGNED NOT NULL,
     1276            snapshot_data LONGTEXT NULL DEFAULT NULL,
     1277            checkpoint_data LONGTEXT NULL DEFAULT NULL,
     1278            status VARCHAR(20) NOT NULL DEFAULT 'pending',
     1279            last_error TEXT NULL DEFAULT NULL,
     1280            started_at DATETIME NULL DEFAULT NULL,
     1281            completed_at DATETIME NULL DEFAULT NULL,
     1282            updated_at DATETIME NOT NULL,
     1283            PRIMARY KEY  (source_id),
     1284            KEY status (status)
     1285        ) ENGINE=InnoDB $collate;",
     1286        "CREATE TABLE {$this->view_source_events_table} (
     1287            id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     1288            source_id BIGINT(20) UNSIGNED NOT NULL,
     1289            source_record_key VARCHAR(191) NOT NULL,
     1290            source_record_hash CHAR(64) NOT NULL,
     1291            product_id BIGINT(20) UNSIGNED NOT NULL,
     1292            record_type VARCHAR(20) NOT NULL DEFAULT 'event',
     1293            period_start DATETIME NULL DEFAULT NULL,
     1294            period_end DATETIME NULL DEFAULT NULL,
     1295            observed_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     1296            applied_views BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     1297            source_fingerprint CHAR(64) NULL DEFAULT NULL,
     1298            status VARCHAR(20) NOT NULL DEFAULT 'pending',
     1299            created_at DATETIME NOT NULL,
     1300            updated_at DATETIME NOT NULL,
     1301            PRIMARY KEY  (id),
     1302            UNIQUE KEY source_record (source_id, source_record_hash),
     1303            KEY product_id (product_id),
     1304            KEY source_product (source_id, product_id),
     1305            KEY source_fingerprint (source_fingerprint),
     1306            KEY product_record_period (product_id, record_type, period_start, period_end),
     1307            KEY status (status)
     1308        ) ENGINE=InnoDB $collate;",
     1309        "CREATE TABLE {$this->view_source_failures_table} (
     1310            id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     1311            source_id BIGINT(20) UNSIGNED NOT NULL,
     1312            origin_key VARCHAR(100) NOT NULL,
     1313            source_record_key VARCHAR(191) NOT NULL,
     1314            source_record_hash CHAR(64) NOT NULL,
     1315            product_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
     1316            attempts INT(10) UNSIGNED NOT NULL DEFAULT 0,
     1317            last_error TEXT NOT NULL,
     1318            status VARCHAR(20) NOT NULL DEFAULT 'retry',
     1319            next_retry_at DATETIME NULL DEFAULT NULL,
     1320            retry_checkpoint LONGTEXT NULL DEFAULT NULL,
     1321            updated_at DATETIME NOT NULL,
     1322            PRIMARY KEY  (id),
     1323            UNIQUE KEY source_record (source_id, source_record_hash),
     1324            KEY status_retry (status, next_retry_at),
     1325            KEY source_origin (source_id, origin_key),
     1326            KEY product_id (product_id)
     1327        ) ENGINE=InnoDB $collate;",
     1328        "CREATE TABLE {$this->view_source_reconciliation_table} (
     1329            id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     1330            event_id BIGINT(20) UNSIGNED NOT NULL,
     1331            status VARCHAR(30) NOT NULL DEFAULT 'unprocessed',
     1332            reason_code VARCHAR(100) NOT NULL,
     1333            reason TEXT NULL DEFAULT NULL,
     1334            matched_event_id BIGINT(20) UNSIGNED NULL DEFAULT NULL,
     1335            evidence LONGTEXT NULL DEFAULT NULL,
     1336            algorithm_version VARCHAR(32) NOT NULL,
     1337            created_at DATETIME NOT NULL,
     1338            updated_at DATETIME NOT NULL,
     1339            PRIMARY KEY  (id),
     1340            UNIQUE KEY event_id (event_id),
     1341            KEY status (status),
     1342            KEY matched_event_id (matched_event_id)
     1343        ) ENGINE=InnoDB $collate;",
     1344        "CREATE TABLE {$this->view_source_reconciliation_history_table} (
     1345            id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
     1346            event_id BIGINT(20) UNSIGNED NOT NULL,
     1347            previous_status VARCHAR(30) NULL DEFAULT NULL,
     1348            new_status VARCHAR(30) NOT NULL,
     1349            reason_code VARCHAR(100) NOT NULL,
     1350            reason TEXT NULL DEFAULT NULL,
     1351            matched_event_id BIGINT(20) UNSIGNED NULL DEFAULT NULL,
     1352            evidence LONGTEXT NULL DEFAULT NULL,
     1353            algorithm_version VARCHAR(32) NOT NULL,
     1354            created_at DATETIME NOT NULL,
     1355            PRIMARY KEY  (id),
     1356            KEY event_id (event_id),
     1357            KEY created_at (created_at)
     1358        ) ENGINE=InnoDB $collate;",
     1359    ];
     1360
     1361    $fail = static function ($message) {
     1362        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Preserve an actionable migration failure in the PHP error log when schema setup cannot complete.
     1363        error_log('[Statora Plus] View-source schema migration failed: ' . $message);
     1364        return false;
     1365    };
     1366
     1367    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     1368
     1369    try {
     1370        foreach ($sql as $statement) {
     1371            dbDelta($statement);
     1372        }
     1373    } catch (Throwable $e) {
     1374        return $fail($e->getMessage());
     1375    }
     1376
     1377    // Convert legacy detection values out of the processing-status column.
     1378    // Existing processing states are preserved; only legacy detection labels are migrated.
     1379    $legacy_status_migration = statpl_db_query(
     1380        "UPDATE {$this->view_source_origins_table}
     1381         SET detection_status = CASE
     1382                 WHEN status IN ('available', 'missing_table', 'incompatible_schema', 'database_error')
     1383                     THEN status
     1384                 ELSE 'unknown'
     1385             END,
     1386             status = 'pending'
     1387         WHERE status IN ('detected', 'available', 'missing_table', 'incompatible_schema', 'database_error')"
     1388    );
     1389
     1390    if ($legacy_status_migration === false || $wpdb->last_error !== '') {
     1391        return $fail('Could not migrate legacy origin detection statuses: ' . $wpdb->last_error);
     1392    }
     1393
     1394    foreach ($tables as $table) {
     1395        // The migration metadata is the allowlist for identifiers used by the
     1396        // SHOW COLUMNS / SHOW INDEX statements below. Reject anything outside it.
     1397        if (
     1398            !is_string($table)
     1399            || !array_key_exists($table, $definitions)
     1400            || !preg_match('/^[A-Za-z0-9_]+$/D', $table)
     1401        ) {
     1402            return $fail('Invalid internal table identifier during schema verification.');
     1403        }
     1404
     1405        $found_table = statpl_db_get_var(
     1406            statpl_db_prepare(
     1407                'SHOW TABLES LIKE %s',
     1408                $wpdb->esc_like($table)
     1409            )
     1410        );
     1411
     1412        if ($wpdb->last_error !== '') {
     1413            return $fail('Could not inspect table existence for ' . $table . ': ' . $wpdb->last_error);
     1414        }
     1415
     1416        if ($found_table !== $table) {
     1417            return $fail('Required table was not created: ' . $table);
     1418        }
     1419
     1420        $table_status = statpl_db_get_row(
     1421            statpl_db_prepare(
     1422                'SHOW TABLE STATUS LIKE %s',
     1423                $wpdb->esc_like($table)
     1424            ),
     1425            ARRAY_A
     1426        );
     1427
     1428        if ($wpdb->last_error !== '') {
     1429            return $fail('Could not inspect table engine for ' . $table . ': ' . $wpdb->last_error);
     1430        }
     1431
     1432        if (!$table_status || !isset($table_status['Engine']) || strcasecmp($table_status['Engine'], 'InnoDB') !== 0) {
     1433            return $fail('Table engine is not InnoDB: ' . $table);
     1434        }
     1435
     1436        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Identifier is built from the trusted WordPress prefix and fixed plugin suffix.
     1437        $column_rows = statpl_db_get_results("SHOW COLUMNS FROM `{$table}`", ARRAY_A);
     1438
     1439        if ($wpdb->last_error !== '') {
     1440            return $fail('Could not inspect columns for ' . $table . ': ' . $wpdb->last_error);
     1441        }
     1442
     1443        if (!is_array($column_rows)) {
     1444            return $fail('Invalid column metadata for ' . $table);
     1445        }
     1446
     1447        $actual_columns = [];
     1448        foreach ($column_rows as $column) {
     1449            $actual_columns[$column['Field']] = $column;
     1450        }
     1451
     1452        $expected_columns = $definitions[$table]['columns'];
     1453
     1454        if (count($actual_columns) !== count($expected_columns)) {
     1455            return $fail('Unexpected column count in ' . $table);
     1456        }
     1457
     1458        foreach ($expected_columns as $name => $expected) {
     1459            if (!isset($actual_columns[$name])) {
     1460                return $fail('Missing column ' . $table . '.' . $name);
     1461            }
     1462
     1463            $actual = $actual_columns[$name];
     1464            $actual_type = strtolower((string) $actual['Type']);
     1465            $actual_null = strtoupper((string) $actual['Null']);
     1466            $actual_default = $actual['Default'];
     1467            $actual_extra = strtolower(trim((string) $actual['Extra']));
     1468            $actual_extra = preg_replace('/\\s+/', ' ', $actual_extra);
     1469            $expected_extra = $expected[3] ? 'auto_increment' : '';
     1470
     1471            if ($actual_type !== $expected[0]) {
     1472                return $fail('Wrong type for ' . $table . '.' . $name . ': ' . $actual_type);
     1473            }
     1474
     1475            if ($actual_null !== $expected[1]) {
     1476                return $fail('Wrong NULL definition for ' . $table . '.' . $name);
     1477            }
     1478
     1479            if ($expected[2] === null) {
     1480                if ($actual_default !== null) {
     1481                    return $fail('Unexpected default for ' . $table . '.' . $name);
     1482                }
     1483            } elseif ((string) $actual_default !== $expected[2]) {
     1484                return $fail('Wrong default for ' . $table . '.' . $name);
     1485            }
     1486
     1487            if ($actual_extra !== $expected_extra) {
     1488                return $fail('Wrong Extra definition for ' . $table . '.' . $name . ': ' . $actual_extra);
     1489            }
     1490        }
     1491
     1492        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Identifier is built from the trusted WordPress prefix and fixed plugin suffix.
     1493        $index_rows = statpl_db_get_results("SHOW INDEX FROM `{$table}`", ARRAY_A);
     1494
     1495        if ($wpdb->last_error !== '') {
     1496            return $fail('Could not inspect indexes for ' . $table . ': ' . $wpdb->last_error);
     1497        }
     1498
     1499        if (!is_array($index_rows)) {
     1500            return $fail('Invalid index metadata for ' . $table);
     1501        }
     1502
     1503        $actual_indexes = [];
     1504        foreach ($index_rows as $index) {
     1505            $key_name = $index['Key_name'];
     1506
     1507            if (!isset($actual_indexes[$key_name])) {
     1508                $actual_indexes[$key_name] = [
     1509                    'unique' => ((int) $index['Non_unique'] === 0),
     1510                    'columns' => [],
     1511                ];
     1512            } elseif ($actual_indexes[$key_name]['unique'] !== ((int) $index['Non_unique'] === 0)) {
     1513                return $fail('Inconsistent uniqueness metadata for index ' . $table . '.' . $key_name);
     1514            }
     1515
     1516            $sequence = (string) $index['Seq_in_index'];
     1517            if (!preg_match('/^[1-9][0-9]*$/D', $sequence)) {
     1518                return $fail('Invalid Seq_in_index for ' . $table . '.' . $key_name);
     1519            }
     1520
     1521            $sequence = (int) $sequence;
     1522            if (isset($actual_indexes[$key_name]['columns'][$sequence])) {
     1523                return $fail('Duplicate Seq_in_index for ' . $table . '.' . $key_name);
     1524            }
     1525
     1526            if (!array_key_exists('Sub_part', $index) || $index['Sub_part'] !== null) {
     1527                return $fail('Unexpected index prefix length for ' . $table . '.' . $key_name);
     1528            }
     1529
     1530            $actual_indexes[$key_name]['columns'][$sequence] = $index['Column_name'];
     1531        }
     1532
     1533        foreach ($actual_indexes as $key_name => &$index_data) {
     1534            ksort($index_data['columns']);
     1535            $positions = array_keys($index_data['columns']);
     1536
     1537            if ($positions !== range(1, count($positions))) {
     1538                return $fail('Non-contiguous Seq_in_index for ' . $table . '.' . $key_name);
     1539            }
     1540
     1541            $index_data['columns'] = array_values($index_data['columns']);
     1542        }
     1543        unset($index_data);
     1544
     1545        $expected_indexes = $definitions[$table]['indexes'];
     1546
     1547        if (count($actual_indexes) !== count($expected_indexes)) {
     1548            return $fail('Unexpected index count in ' . $table);
     1549        }
     1550
     1551        foreach ($expected_indexes as $name => $expected) {
     1552            if (!isset($actual_indexes[$name])) {
     1553                return $fail('Missing index ' . $table . '.' . $name);
     1554            }
     1555
     1556            if (
     1557                $actual_indexes[$name]['unique'] !== $expected[0]
     1558                || $actual_indexes[$name]['columns'] !== $expected[1]
     1559            ) {
     1560                return $fail('Incorrect index definition for ' . $table . '.' . $name);
     1561            }
     1562        }
     1563    }
     1564
     1565    update_option('statpl_view_source_schema_version', $schema_version, false);
     1566
     1567    if (get_option('statpl_view_source_schema_version', '') !== $schema_version) {
     1568        return $fail('Could not persist the verified view-source schema version.');
     1569    }
     1570
     1571    return true;
     1572}
     1573
     1574    /**
     1575     * Reclassify legacy aggregate matches that relied on the synthetic
     1576     * product/date/count fingerprint. This is audit-only: totals and
     1577     * applied_views are deliberately not modified because the old records
     1578     * cannot prove whether the underlying views were actually duplicated.
     1579     */
     1580    private function migrate_legacy_aggregate_fingerprint_matches() {
     1581        global $wpdb;
     1582
     1583        $complete_option = 'statpl_legacy_aggregate_fp_migration_v1_complete';
     1584        $cursor_option = 'statpl_legacy_aggregate_fp_migration_v1_cursor';
     1585        if (get_option($complete_option, '') === '1') {
     1586            return true;
     1587        }
     1588
     1589        $lock_name = 'statpl_legacy_agg_' . substr(hash('sha256', $wpdb->prefix), 0, 40);
     1590        $migration_lock = $this->acquire_generic_advisory_lock($lock_name);
     1591        if (empty($migration_lock['acquired'])) {
     1592            $this->schedule_legacy_aggregate_reconciliation_migration(60);
     1593            return false;
     1594        }
     1595
     1596        $batch_size = 100;
     1597        $cursor = max(0, (int) get_option($cursor_option, 0));
     1598        $processed = 0;
     1599        $failed = false;
     1600        $migration_transaction_started = false;
     1601        $migration_transaction_committed = false;
     1602
     1603        try {
     1604            $rows = statpl_db_get_results(statpl_db_prepare(
     1605                "SELECT r.event_id, r.status, r.reason_code, r.reason, r.matched_event_id,
     1606                        r.evidence, r.algorithm_version,
     1607                        e.source_id, e.product_id, e.record_type, e.observed_views,
     1608                        e.period_start, e.period_end, e.source_fingerprint,
     1609                        m.source_id AS matched_source_id,
     1610                        m.record_type AS matched_record_type,
     1611                        m.source_fingerprint AS matched_source_fingerprint
     1612                 FROM {$this->view_source_reconciliation_table} r
     1613                 INNER JOIN {$this->view_source_events_table} e ON e.id = r.event_id
     1614                 INNER JOIN {$this->view_source_events_table} m ON m.id = r.matched_event_id
     1615                 WHERE r.event_id > %d
     1616                   AND r.status = 'matched_exact'
     1617                   AND r.reason_code = 'shared_fingerprint_exact_match'
     1618                   AND e.record_type = 'aggregate'
     1619                   AND m.record_type = 'aggregate'
     1620                   AND e.source_id <> m.source_id
     1621                 ORDER BY r.event_id ASC
     1622                 LIMIT %d",
     1623                $cursor,
     1624                $batch_size
     1625            ), ARRAY_A);
     1626
     1627            if (!is_array($rows)) {
     1628                $failed = true;
     1629                return false;
     1630            }
     1631
     1632            foreach ($rows as $row) {
     1633                $event_id = (int) $row['event_id'];
     1634                $expected_fingerprint = '';
     1635                if ($row['period_start'] !== null && $row['period_end'] !== null) {
     1636                    $expected_fingerprint = hash(
     1637                        'sha256',
     1638                        "statpl-product-day-aggregate-v1\0"
     1639                        . (int) $row['product_id'] . "\0"
     1640                        . (string) $row['period_start'] . "\0"
     1641                        . (string) $row['period_end'] . "\0"
     1642                        . (int) $row['observed_views']
     1643                    );
     1644                }
     1645
     1646                $is_legacy_synthetic_match = $expected_fingerprint !== ''
     1647                    && is_string($row['source_fingerprint'])
     1648                    && is_string($row['matched_source_fingerprint'])
     1649                    && hash_equals($expected_fingerprint, strtolower($row['source_fingerprint']))
     1650                    && hash_equals($expected_fingerprint, strtolower($row['matched_source_fingerprint']));
     1651
     1652                if ($is_legacy_synthetic_match) {
     1653                    $evidence = json_decode((string) $row['evidence'], true);
     1654                    if (!is_array($evidence)) {
     1655                        $evidence = ['legacy_evidence_raw' => (string) $row['evidence']];
     1656                    }
     1657                    $evidence['legacy_reclassification'] = [
     1658                        'migration' => 'legacy_aggregate_fingerprint_v1',
     1659                        'previous_status' => 'matched_exact',
     1660                        'new_status' => 'legacy_exact_unverified',
     1661                        'reason' => 'The previous synthetic fingerprint represented product, date and count, not a shared view-event identity. The old exact-match decision is therefore unverified.',
     1662                        'totals_changed' => false,
     1663                        'applied_views_changed' => false,
     1664                    ];
     1665                    $encoded_evidence = wp_json_encode($evidence);
     1666                    if (!is_string($encoded_evidence)) {
     1667                        $failed = true;
     1668                        return false;
     1669                    }
     1670
     1671                    if (!$this->generic_advisory_lock_session_is_current($migration_lock)) {
     1672                        $failed = true;
     1673                        return false;
     1674                    }
     1675
     1676                    $migration_transaction_started = false;
     1677                    $migration_transaction_committed = false;
     1678                    $transaction_started = (statpl_db_query('START TRANSACTION') !== false);
     1679                    if (!$transaction_started) {
     1680                        $failed = true;
     1681                        return false;
     1682                    }
     1683
     1684                    $migration_transaction_started = true;
     1685                    if (!$this->generic_advisory_lock_session_is_current($migration_lock)) {
     1686                        // Lock ownership and connection identity are separate checks.
     1687                        // Roll back only when this is still the original DB session.
     1688                        if ($this->generic_db_session_matches_lock($migration_lock)) {
     1689                            statpl_db_query('ROLLBACK');
     1690                            $migration_transaction_started = false;
     1691                        }
     1692                        $failed = true;
     1693                        return false;
     1694                    }
     1695                    $updated = statpl_db_update(
     1696                        $this->view_source_reconciliation_table,
     1697                        [
     1698                            'status' => 'legacy_exact_unverified',
     1699                            'reason_code' => 'legacy_aggregate_fingerprint_unverified',
     1700                            'reason' => 'Legacy exact match based on product/date/count only; actual shared view identity was not established. Totals were intentionally left unchanged pending a controlled reconciliation decision.',
     1701                            'evidence' => $encoded_evidence,
     1702                            'algorithm_version' => '1.2.0',
     1703                            'updated_at' => current_time('mysql'),
     1704                        ],
     1705                        ['event_id' => $event_id, 'status' => 'matched_exact']
     1706                    );
     1707                    $history = $updated === 1 ? statpl_db_insert(
     1708                        $this->view_source_reconciliation_history_table,
     1709                        [
     1710                            'event_id' => $event_id,
     1711                            'previous_status' => 'matched_exact',
     1712                            'new_status' => 'legacy_exact_unverified',
     1713                            'reason_code' => 'legacy_aggregate_fingerprint_unverified',
     1714                            'reason' => 'Reclassified because the legacy aggregate fingerprint did not prove shared view-event identity. No totals or applied_views were changed.',
     1715                            'matched_event_id' => (int) $row['matched_event_id'],
     1716                            'evidence' => $encoded_evidence,
     1717                            'algorithm_version' => '1.2.0',
     1718                            'created_at' => current_time('mysql'),
     1719                        ]
     1720                    ) : false;
     1721
     1722                    if ($updated !== 1 || $history !== 1
     1723                        || !$this->generic_advisory_lock_session_is_current($migration_lock)
     1724                        || statpl_db_query('COMMIT') === false) {
     1725                        // Roll back only if the original lock-owning session is still current.
     1726                        // A reconnect implicitly abandons the old transaction; never issue
     1727                        // transaction control against an unverified replacement session.
     1728                        if ($migration_transaction_started && $this->generic_db_session_matches_lock($migration_lock)) {
     1729                            statpl_db_query('ROLLBACK');
     1730                            $migration_transaction_started = false;
     1731                        }
     1732                        $failed = true;
     1733                        return false;
     1734                    }
     1735                    $migration_transaction_committed = true;
     1736                    $migration_transaction_started = false;
     1737                }
     1738
     1739                $cursor = $event_id;
     1740                // Persist the cursor only while this exact MySQL session still owns
     1741                // the migration lock. A PHP-level check followed by update_option()
     1742                // would leave a reconnect race between the check and the write.
     1743                $cursor_persisted = statpl_db_query(statpl_db_prepare(
     1744                    "INSERT INTO {$wpdb->options} (option_name, option_value, autoload)
     1745                     SELECT %s, %s, 'no'
     1746                     WHERE IS_USED_LOCK(%s) = CONNECTION_ID()
     1747                     ON DUPLICATE KEY UPDATE option_value = IF(
     1748                         IS_USED_LOCK(%s) = CONNECTION_ID(),
     1749                         VALUES(option_value),
     1750                         option_value
     1751                     )",
     1752                    $cursor_option,
     1753                    (string) $cursor,
     1754                    $lock_name,
     1755                    $lock_name
     1756                ));
     1757                if (!in_array($cursor_persisted, [0, 1, 2], true)
     1758                    || !$this->generic_advisory_lock_session_is_current($migration_lock)) {
     1759                    $failed = true;
     1760                    return false;
     1761                }
     1762                $persisted_cursor = statpl_db_get_var(statpl_db_prepare(
     1763                    "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
     1764                    $cursor_option
     1765                ));
     1766                if (!empty($wpdb->last_error) || (int) $persisted_cursor !== $cursor) {
     1767                    $failed = true;
     1768                    return false;
     1769                }
     1770                $processed++;
     1771            }
     1772
     1773            if (count($rows) < $batch_size) {
     1774                // Completion is also lock-owner guarded so a stale/reconnected
     1775                // worker cannot mark an unfinished migration complete.
     1776                $complete_persisted = statpl_db_query(statpl_db_prepare(
     1777                    "INSERT INTO {$wpdb->options} (option_name, option_value, autoload)
     1778                     SELECT %s, '1', 'no'
     1779                     WHERE IS_USED_LOCK(%s) = CONNECTION_ID()
     1780                     ON DUPLICATE KEY UPDATE option_value = IF(
     1781                         IS_USED_LOCK(%s) = CONNECTION_ID(), '1', option_value
     1782                     )",
     1783                    $complete_option,
     1784                    $lock_name,
     1785                    $lock_name
     1786                ));
     1787                $persisted_completion = statpl_db_get_var(statpl_db_prepare(
     1788                    "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
     1789                    $complete_option
     1790                ));
     1791                $completion_verified = in_array($complete_persisted, [0, 1, 2], true)
     1792                    && $this->generic_advisory_lock_session_is_current($migration_lock)
     1793                    && empty($wpdb->last_error)
     1794                    && (string) $persisted_completion === '1';
     1795                if (!$completion_verified) {
     1796                    $failed = true;
     1797                }
     1798                return $completion_verified;
     1799            }
     1800
     1801            $this->schedule_legacy_aggregate_reconciliation_migration(30);
     1802            return true;
     1803        } catch (Throwable $exception) {
     1804            if (defined('WP_DEBUG') && WP_DEBUG) {
     1805                $this->debug_log('[Statora Plus] Legacy aggregate reconciliation audit migration failed: ' . $exception->getMessage());
     1806            }
     1807            $failed = true;
     1808            return false;
     1809        } finally {
     1810            if ($migration_transaction_started && !$migration_transaction_committed
     1811                && $this->generic_db_session_matches_lock($migration_lock)) {
     1812                statpl_db_query('ROLLBACK');
     1813                $migration_transaction_started = false;
     1814            }
     1815            $this->release_generic_advisory_lock($migration_lock, 'legacy_aggregate_fingerprint_migration');
     1816            if ($failed) {
     1817                $this->schedule_legacy_aggregate_reconciliation_migration(60);
     1818                if (defined('WP_DEBUG') && WP_DEBUG) {
     1819                    $this->debug_log('[Statora Plus] Legacy aggregate reconciliation audit migration was rescheduled after a failed batch.');
     1820                }
     1821            }
     1822        }
     1823    }
     1824
     1825    /**
     1826     * Schedule the audit migration outside the normal frontend initialization path.
     1827     * Action Scheduler is preferred; WP-Cron remains the compatibility fallback.
     1828     */
     1829    public function schedule_legacy_aggregate_reconciliation_migration($delay = 15) {
     1830        if (get_option('statpl_legacy_aggregate_fp_migration_v1_complete', '') === '1') {
     1831            return;
     1832        }
     1833
     1834        $delay = max(5, (int) $delay);
     1835        $hook = 'statpl_reconcile_legacy_aggregate_fingerprints';
     1836        $args = [];
     1837        $group = 'statpl-plus';
     1838
     1839        if (function_exists('as_schedule_single_action') && !did_action('action_scheduler_init')) {
     1840            add_action('action_scheduler_init', [$this, 'schedule_legacy_aggregate_reconciliation_migration'], 10, 0);
     1841            return;
     1842        }
     1843
     1844        if (
     1845            function_exists('as_has_scheduled_action')
     1846            && did_action('action_scheduler_init')
     1847            && as_has_scheduled_action($hook, $args, $group)
     1848        ) {
     1849            return;
     1850        }
     1851
     1852        if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) {
     1853            $action_id = as_schedule_single_action(time() + $delay, $hook, $args, $group);
     1854            if ($action_id) {
     1855                return;
     1856            }
     1857        }
     1858
     1859        if (!wp_next_scheduled($hook, $args)) {
     1860            wp_schedule_single_event(time() + $delay, $hook, $args);
     1861        }
     1862    }
     1863
     1864    public function init_legacy_aggregate_reconciliation_migration() {
     1865        $this->migrate_legacy_aggregate_fingerprint_matches();
     1866    }
     1867
     1868     public function init() {
     1869        // Register runtime hooks before touching the database. If MySQL is
     1870        // temporarily unavailable during plugins_loaded, frontend/AJAX and
     1871        // WooCommerce hooks must still exist for the remainder of this request.
    1101872        add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend']);
    1111873        add_action('wp_ajax_statpl_record_view', [$this, 'record_view']);
    … …  
    1131875        add_action('admin_menu', [$this, 'admin_menu']);
    1141876        add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_styles']);
     1877        add_action('admin_enqueue_scripts', [$this, 'enqueue_deactivation_prompt']);
    1151878        add_action('admin_notices', [$this, 'woocommerce_notice']);
     1879        add_action('admin_footer', [$this, 'enqueue_historical_notice_script']);
    1161880        add_action('woocommerce_order_status_cancelled', [$this, 'record_cancel_event'], 10, 2);
    1171881        add_action('woocommerce_order_refunded', [$this, 'record_refund_event'], 10, 2);
    118         // Invalidate cached aggregates when order data or status changes.
    1191882        foreach (['woocommerce_new_order', 'woocommerce_update_order', 'woocommerce_order_status_changed', 'woocommerce_order_payment_status_changed', 'woocommerce_saved_order_items'] as $hook) {
    1201883            add_action($hook, [$this, 'invalidate_order_stats_cache'], 10, 3);
    1211884        }
    1221885        add_action('woocommerce_order_refunded', [$this, 'invalidate_order_stats_cache'], 20, 2);
    123         // A refund changes net quantity/revenue in the order snapshot as well as refund-event history.
    1241886        add_action('woocommerce_order_refunded', [$this, 'queue_order_snapshot'], 30, 2);
    1251887        add_action('woocommerce_before_delete_order', [$this, 'delete_order_snapshot'], 10, 1);
    … …  
    1281890        }
    1291891        add_action('statpl_import_batch', [$this, 'run_import_batch']);
     1892        add_action('statpl_view_import_batch', [$this, 'run_slimstat_view_import_batch']);
     1893        add_action('statpl_generic_view_import_batch', [$this, 'run_generic_historical_view_import_batch'], 10, 2);
     1894        add_action('statpl_generic_view_snapshot_retry', [$this, 'retry_generic_historical_view_snapshot'], 10, 2);
     1895        add_action('statpl_generic_view_retry_failed_records', [$this, 'retry_generic_historical_view_failures'], 10, 2);
    1301896        add_action('statpl_sync_order', [$this, 'sync_order_snapshot'], 10, 1);
    131         if (!get_option('statpl_import_complete')) { $this->schedule_import_batch(); }
    132     }
     1897        add_action('statpl_reconcile_legacy_aggregate_fingerprints', [$this, 'init_legacy_aggregate_reconciliation_migration']);
     1898
     1899        global $wpdb;
     1900        // Do not interpret failed option/table reads as missing state. A simple
     1901        // round-trip probe distinguishes a usable connection from a DB outage.
     1902        try {
     1903            $database_ready = isset($wpdb) && is_object($wpdb)
     1904                && (string) statpl_db_get_var('SELECT 1') === '1'
     1905                && empty($wpdb->last_error);
     1906        } catch (Throwable $exception) {
     1907            $database_ready = false;
     1908        }
     1909        if (!$database_ready) {
     1910            if (defined('WP_DEBUG') && WP_DEBUG) {
     1911                $this->debug_log('[Statora Plus] Database unavailable during initialization; schema upgrades and historical discovery will retry on the next request. Runtime hooks remain registered.');
     1912            }
     1913            return;
     1914        }
     1915
     1916        try {
     1917            if (get_option('statpl_db_version') !== self::VERSION) {
     1918                // Core tables must be verified before historical schema upgrades
     1919                // or adapter discovery can run. A failed dbDelta pass must leave
     1920                // the version marker unchanged so the next request retries it.
     1921                if (!$this->activate()) {
     1922                    if (defined('WP_DEBUG') && WP_DEBUG) {
     1923                        $this->debug_log('[Statora Plus] Core schema verification failed; initialization will retry on the next request.');
     1924                    }
     1925                    return;
     1926                }
     1927            }
     1928            $this->maybe_upgrade_view_import_schema();
     1929            $this->maybe_upgrade_view_import_failures_schema();
     1930            $this->maybe_upgrade_view_import_state_schema();
     1931            $this->maybe_upgrade_view_import_events_schema();
     1932            $this->maybe_upgrade_view_source_schema();
     1933            $this->maybe_upgrade_historical_import_report_schema();
     1934        } catch (Throwable $exception) {
     1935            if (defined('WP_DEBUG') && WP_DEBUG) {
     1936                $this->debug_log('[Statora Plus] Schema initialization interrupted; version markers remain subject to verification and initialization will retry on the next request.');
     1937            }
     1938            return;
     1939        }
     1940
     1941        // Schema helpers intentionally leave their version markers unchanged
     1942        // when verification fails. Do not let discovery/workers query partially
     1943        // initialized tables; the next request will retry the outstanding steps.
     1944        $historical_schema_ready =
     1945            get_option('statpl_view_import_schema_version', '') === self::VIEW_IMPORT_SCHEMA_VERSION
     1946            && get_option('statpl_view_import_failures_schema_version', '') === self::VIEW_IMPORT_FAILURES_SCHEMA_VERSION
     1947            && get_option('statpl_view_import_state_schema_version', '') === self::VIEW_IMPORT_STATE_SCHEMA_VERSION
     1948            && get_option('statpl_view_import_events_schema_version', '') === self::VIEW_IMPORT_EVENTS_SCHEMA_VERSION
     1949            && get_option('statpl_view_source_schema_version', '') === self::VIEW_SOURCE_SCHEMA_VERSION
     1950            && get_option('statpl_history_report_schema_version', '') === self::HISTORY_REPORT_SCHEMA_VERSION;
     1951        if (!$historical_schema_ready) {
     1952            if (defined('WP_DEBUG') && WP_DEBUG) {
     1953                $this->debug_log('[Statora Plus] Historical schema is not fully verified; historical discovery and recovery are deferred until a later request.');
     1954            }
     1955            return;
     1956        }
     1957
     1958        // Schedule the audit migration outside the normal request path. The worker
     1959        // uses bounded batches and a connection-scoped lock for safe continuation.
     1960        $this->schedule_legacy_aggregate_reconciliation_migration(15);
     1961
     1962        // Keep each startup subsystem isolated. A scheduler or adapter-provider
     1963        // failure in one subsystem must not prevent the remaining durable work
     1964        // from being discovered and recovered on this request.
     1965        if (!get_option('statpl_import_complete')) {
     1966            try {
     1967                $this->schedule_import_batch();
     1968            } catch (Throwable $exception) {
     1969                if (defined('WP_DEBUG') && WP_DEBUG) {
     1970                    $this->debug_log('[Statora Plus] Core import scheduling failed; it will be retried on a later request.');
     1971                }
     1972            }
     1973        }
     1974
     1975        // SlimStat remains on its existing, independent initialization path.
     1976        // Contain unexpected failures at the call site so they cannot block
     1977        // generic adapter discovery or recovery.
     1978        try {
     1979            $slimstat_result = $this->initialize_slimstat_view_import();
     1980            if (isset($slimstat_result['status']) && $slimstat_result['status'] !== 'already_initialized') {
     1981                update_option('statpl_history_slimstat_probe', sanitize_key((string) $slimstat_result['status']), false);
     1982            } elseif (($slimstat_result['status'] ?? '') === 'already_initialized') {
     1983                $slimstat_state_status = statpl_db_get_var(statpl_db_prepare(
     1984                    "SELECT status FROM {$this->view_import_state_table} WHERE source = %s", 'slimstat'
     1985                ));
     1986                if (in_array((string) $slimstat_state_status, ['pending','running'], true)) {
     1987                    $slimstat_probe_result = $this->get_slimstat_view_snapshot();
     1988                    update_option('statpl_history_slimstat_probe', sanitize_key((string) ($slimstat_probe_result['status'] ?? 'database_error')), false);
     1989                }
     1990            }
     1991            if (
     1992                is_array($slimstat_result)
     1993                && isset($slimstat_result['status'])
     1994                && in_array($slimstat_result['status'], ['initialized', 'already_initialized'], true)
     1995            ) {
     1996                try {
     1997                    $this->schedule_slimstat_view_import_batch();
     1998                } catch (Throwable $exception) {
     1999                    if (defined('WP_DEBUG') && WP_DEBUG) {
     2000                        $this->debug_log('[Statora Plus] SlimStat import scheduling failed; its existing recovery path will retry.');
     2001                    }
     2002                }
     2003            }
     2004        } catch (Throwable $exception) {
     2005            if (defined('WP_DEBUG') && WP_DEBUG) {
     2006                $this->debug_log('[Statora Plus] SlimStat initialization failed; generic historical sources will continue independently.');
     2007            }
     2008        }
     2009
     2010        // Discovery failure must not suppress recovery of already persisted
     2011        // work. Conversely, recovery is attempted even if discovery throws.
     2012        try {
     2013            $generic_result = $this->discover_historical_view_adapters();
     2014            update_option('statpl_history_discovery_status', sanitize_key((string) ($generic_result['status'] ?? 'unknown')), false);
     2015            if (!empty($generic_result['registered'])) {
     2016                try {
     2017                    $this->schedule_generic_historical_view_import_batch(15);
     2018                } catch (Throwable $exception) {
     2019                    if (defined('WP_DEBUG') && WP_DEBUG) {
     2020                        $this->debug_log('[Statora Plus] Generic historical import scheduling failed; durable work will be recovered later.');
     2021                    }
     2022                }
     2023            }
     2024        } catch (Throwable $exception) {
     2025            if (defined('WP_DEBUG') && WP_DEBUG) {
     2026                $this->debug_log('[Statora Plus] Generic adapter discovery failed; persisted historical work recovery will still run.');
     2027            }
     2028        }
     2029
     2030        if (get_option('statpl_retained_deactivation_at', '') !== '') {
     2031            $gap_result = $this->resume_retained_history_gap();
     2032            if (($gap_result['status'] ?? '') === 'reset') {
     2033                try {
     2034                    $this->schedule_slimstat_view_import_batch(15);
     2035                } catch (Throwable $exception) {
     2036                    if (defined('WP_DEBUG') && WP_DEBUG) {
     2037                        $this->debug_log('[Statora Plus] Retained history recovery scheduling failed; it will be retried on a later request.');
     2038                    }
     2039                }
     2040            }
     2041        }
     2042
     2043        try {
     2044            $this->recover_unscheduled_generic_historical_work();
     2045        } catch (Throwable $exception) {
     2046            if (defined('WP_DEBUG') && WP_DEBUG) {
     2047                $this->debug_log('[Statora Plus] Generic historical work recovery failed; it will be retried on a later request.');
     2048            }
     2049        }
     2050        $lifecycle = $this->get_historical_import_lifecycle_state();
     2051        update_option('statpl_history_import_state', sanitize_key((string) ($lifecycle['state'] ?? 'importing')), false);
     2052    }
     2053
    1332054    public function woocommerce_notice() {
    1342055        if (!current_user_can('manage_options') || class_exists('WooCommerce')) return;
    … …  
    1632084        $now = current_time('mysql');
    1642085        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is built from the WordPress prefix and a fixed plugin suffix; this atomic view-count write must not be cached.
    165         $wpdb->query($wpdb->prepare(
     2086        statpl_db_query(statpl_db_prepare(
    1662087            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Internal table identifier uses the WordPress prefix plus a fixed plugin suffix; product ID is prepared.
    1672088            "INSERT INTO {$this->views_table} (product_id, total_views) VALUES (%d, 1)
    … …  
    1692090        ));
    1702091        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Event writes must be persisted immediately and cannot be cached.
    171         $wpdb->insert($this->events_table, ['product_id' => $product_id, 'viewed_at' => $now], ['%d','%s']);
     2092        statpl_db_insert($this->events_table, ['product_id' => $product_id, 'viewed_at' => $now], ['%d','%s']);
    1722093        setcookie($cookie, '1', time() + 1200, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
    1732094        $_COOKIE[$cookie] = '1';
    … …  
    1752096        $cutoff = gmdate('Y-m-d H:i:s', time() - 172800);
    1762097        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier is internal and the cutoff is prepared; this cleanup deletes expired rows, so caching is not applicable.
    177         $wpdb->query($wpdb->prepare("DELETE FROM {$this->events_table} WHERE viewed_at < %s", get_date_from_gmt($cutoff)));
     2098        statpl_db_query(statpl_db_prepare("DELETE FROM {$this->events_table} WHERE viewed_at < %s", get_date_from_gmt($cutoff)));
    1782099        wp_send_json_success(['counted' => true]);
    1792100    }
    … …  
    1872108        // This prevents a later snapshot sync from making an older cancellation appear to happen today.
    1882109        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; the query values are prepared.
    189         $event_at = $wpdb->get_var($wpdb->prepare(
     2110        $event_at = statpl_db_get_var(statpl_db_prepare(
    1902111            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; both query values are prepared.
    1912112            "SELECT MIN(event_at) FROM {$this->order_events_table} WHERE order_id = %d AND event_type = %s AND event_ref_id = 0",
    … …  
    2802201        // from being counted more than once in the store overview.
    2812202        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all event values are prepared.
    282         $wpdb->query($wpdb->prepare(
     2203        statpl_db_query(statpl_db_prepare(
    2832204            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; all event values are prepared.
    2842205            "INSERT INTO {$this->order_events_table} (order_id, product_id, event_type, event_ref_id, event_at) VALUES (%d, 0, %s, %d, %s) ON DUPLICATE KEY UPDATE event_at=LEAST(event_at, VALUES(event_at))",
    … …  
    2882209        foreach (array_keys($product_ids) as $product_id) {
    2892210            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all event values are prepared.
    290             $wpdb->query($wpdb->prepare(
     2211            statpl_db_query(statpl_db_prepare(
    2912212                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; all event values are prepared.
    2922213                "INSERT INTO {$this->order_events_table} (order_id, product_id, event_type, event_ref_id, event_at) VALUES (%d, %d, %s, %d, %s) ON DUPLICATE KEY UPDATE event_at=LEAST(event_at, VALUES(event_at))",
    … …  
    3092230        // Product ID 0 is reserved for order-level dashboard aggregation.
    3102231        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all event values are prepared.
    311         $wpdb->query($wpdb->prepare(
     2232        statpl_db_query(statpl_db_prepare(
    3122233            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; all event values are prepared.
    3132234            "INSERT INTO {$this->order_events_table} (order_id, product_id, event_type, event_ref_id, event_at) VALUES (%d, 0, %s, 0, %s) ON DUPLICATE KEY UPDATE event_at=LEAST(event_at, VALUES(event_at))",
    … …  
    3162237        foreach ($this->order_product_ids($order) as $product_id) {
    3172238            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all event values are prepared.
    318             $wpdb->query($wpdb->prepare(
     2239            statpl_db_query(statpl_db_prepare(
    3192240                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; all event values are prepared.
    3202241                "INSERT INTO {$this->order_events_table} (order_id, product_id, event_type, event_ref_id, event_at) VALUES (%d, %d, %s, 0, %s) ON DUPLICATE KEY UPDATE event_at=LEAST(event_at, VALUES(event_at))",
    … …  
    3252246        delete_transient('statpl_global_summary_v2');
    3262247    }
     2248    /** Add a two-choice confirmation dialog to this plugin's Plugins-screen action. */
     2249    public function enqueue_deactivation_prompt($hook_suffix) {
     2250        if ($hook_suffix !== 'plugins.php' || !current_user_can('activate_plugins')) {
     2251            return;
     2252        }
     2253        wp_enqueue_script('jquery-core');
     2254        $config = [
     2255            'plugin' => plugin_basename(__FILE__),
     2256            'endpoint' => admin_url('admin-post.php'),
     2257            'nonce' => wp_create_nonce('statpl_prepare_deactivation'),
     2258            'title' => 'Απενεργοποίηση Statora Plus',
     2259            'message' => 'Τι θέλεις να γίνει με τα στατιστικά και τα δεδομένα του Statora Plus;',
     2260            'retain' => 'Ναι, διατήρηση όλων των δεδομένων',
     2261            'delete' => 'Όχι, διαγραφή όλων των δεδομένων',
     2262            'cancel' => 'Ακύρωση',
     2263            'warning' => 'Η διαγραφή δεν μπορεί να αναιρεθεί.',
     2264        ];
     2265        wp_add_inline_script('jquery-core', 'window.StatoraDeactivatePrompt = ' . wp_json_encode($config) . ';', 'before');
     2266        $js = <<<'STATPL_DEACTIVATION_JS'
     2267(function () {
     2268    var cfg = window.StatoraDeactivatePrompt;
     2269    if (!cfg) return;
     2270    function findDeactivateLink(target) {
     2271        var link = target.closest('a');
     2272        if (!link) return null;
     2273        try {
     2274            var url = new URL(link.href, window.location.href);
     2275            if (url.searchParams.get('action') !== 'deactivate' || url.searchParams.get('plugin') !== cfg.plugin) return null;
     2276            return link;
     2277        } catch (e) { return null; }
     2278    }
     2279    document.addEventListener('click', function (event) {
     2280        var link = findDeactivateLink(event.target);
     2281        if (!link) return;
     2282        event.preventDefault();
     2283        event.stopImmediatePropagation();
     2284        if (document.getElementById('statpl-deactivation-dialog')) return;
     2285        var dialog = document.createElement('div');
     2286        dialog.id = 'statpl-deactivation-dialog';
     2287        dialog.style.cssText = 'position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;padding:20px';
     2288        var panel = document.createElement('div');
     2289        panel.style.cssText = 'background:#fff;max-width:520px;width:100%;padding:24px;border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,.25);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif';
     2290        var title = document.createElement('h2'); title.textContent = cfg.title; title.style.marginTop = '0';
     2291        var message = document.createElement('p'); message.textContent = cfg.message;
     2292        var warning = document.createElement('p'); warning.textContent = cfg.warning; warning.style.color = '#b32d2e';
     2293        var actions = document.createElement('div'); actions.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;margin-top:20px';
     2294        function button(label, choice, primary) {
     2295            var b = document.createElement('button'); b.type = 'button'; b.textContent = label;
     2296            b.className = primary ? 'button button-primary' : 'button';
     2297            b.addEventListener('click', function () {
     2298                if (!choice) { dialog.remove(); return; }
     2299                var destination = new URL(cfg.endpoint, window.location.href);
     2300                destination.searchParams.set('action', 'statpl_prepare_deactivation');
     2301                destination.searchParams.set('choice', choice);
     2302                destination.searchParams.set('_wpnonce', cfg.nonce);
     2303                destination.searchParams.set('redirect_to', link.href);
     2304                window.location.assign(destination.toString());
     2305            });
     2306            actions.appendChild(b);
     2307        }
     2308        button(cfg.retain, 'retain', true);
     2309        button(cfg.delete, 'delete', false);
     2310        button(cfg.cancel, '', false);
     2311        panel.appendChild(title); panel.appendChild(message); panel.appendChild(warning); panel.appendChild(actions);
     2312        dialog.appendChild(panel); document.body.appendChild(dialog);
     2313        dialog.addEventListener('click', function (e) { if (e.target === dialog) dialog.remove(); });
     2314    }, true);
     2315})();
     2316STATPL_DEACTIVATION_JS;
     2317        wp_add_inline_script('jquery-core', $js, 'after');
     2318    }
     2319
    3272320    public function enqueue_admin_styles($hook_suffix) {
    3282321        if ('toplevel_page_statpl-product-metrics' !== $hook_suffix) {
    … …  
    3332326        $css = <<<'STATPL_CSS'
    3342327        .statpl-table-scroll{overflow-x:auto}
    335         .statpl-stock-not-tracked{color:#646970}
     2328        .statpl-stock-not-tracked{color:#646970}.statpl-product-visibility{display:inline-block;margin-left:7px;color:#646970;font-size:12px;font-weight:400}
    3362329        .statpl-metrics-wrap .nav-tab{border-color:#c9d7ff;background:#f2f6ff;color:#3155F5;font-weight:600}
    3372330        .statpl-metrics-wrap .nav-tab-active{background:#fff;color:#1d2327;border-bottom-color:#fff;border-top:3px solid #3155F5}
    … …  
    3512344        .statpl-metrics-wrap .notice-success.inline p{color:#263b8f}
    3522345        .statpl-metrics-wrap .statpl-overview-note{color:#505b6b}
     2346        .statpl-history-notice{position:relative;box-sizing:border-box;margin:15px 0;padding:14px 42px 14px 14px;border:1px solid #c5e2a9;border-radius:6px;background:#DDF2C8;color:#000}.statpl-history-notice-importing{background:#D9EEFF;border-color:#b8dcf5}.statpl-history-notice-icon{color:#2271b1;margin-right:6px;vertical-align:middle}.statpl-history-notice p{margin:8px 0;color:#000}.statpl-history-notice strong{color:#000}.statpl-history-notice-close{position:absolute;top:5px;right:8px;padding:0 5px;border:0;background:transparent;color:#44515e;font-size:22px;line-height:1.2;cursor:pointer}.statpl-history-report-link{display:inline-block;margin-top:3px;color:#0073aa;text-decoration:none}.statpl-history-report-link:hover{text-decoration:underline}
    3532347        .statpl-metrics-wrap .statpl-metrics-toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap;margin:18px 0 12px}.statpl-metrics-wrap .statpl-sort-form{display:flex;align-items:center;gap:8px}.statpl-metrics-wrap .statpl-metrics-search{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin:0}.statpl-metrics-wrap .statpl-metrics-search input[type=search]{width:min(375px,100%)}.statpl-metrics-wrap .statpl-metrics-search input[type=submit],.statpl-metrics-wrap .button-primary{background:#3155F5!important;border-color:#3155F5!important;color:#fff!important}
    3542348        .statpl-metrics-wrap .widefat{border-color:#c9d7ff;border-collapse:separate;border-spacing:0}
    … …  
    3662360
    3672361    public function admin_menu() {
     2362        // The detailed import report is intentionally not a permanent menu item.
     2363        // It is reachable only through the temporary notice link for partial or
     2364        // technical outcomes, and only during the 15-minute access window.
    3682365        add_menu_page('Statora Plus', 'Statora Plus', 'manage_woocommerce', 'statpl-product-metrics', [$this, 'render_page'], 'dashicons-chart-bar', 56);
     2366    }
     2367
     2368    /** Add the silent 20-second dismissal behavior; manual close cancels the timer. */
     2369    public function enqueue_historical_notice_script() {
     2370        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a read-only admin-screen routing check; it does not process form data or perform a state-changing action. Mutating admin actions use their own nonce verification.
     2371        if (!isset($_GET['page']) || !in_array(sanitize_key(wp_unslash($_GET['page'])), ['statpl-product-metrics','statpl-history-report'], true)) { return; }
     2372        echo '<script>(function(){var n=document.querySelector(".statpl-history-notice");if(!n)return;var t=window.setTimeout(function(){if(n.parentNode)n.parentNode.removeChild(n);},20000);var b=n.querySelector(".statpl-history-notice-close");if(b)b.addEventListener("click",function(){window.clearTimeout(t);if(n.parentNode)n.parentNode.removeChild(n);});})();</script>';
    3692373    }
    3702374    /** Bulk-load view counts for a product set to avoid two or more queries per product. */
    … …  
    3762380        return $this->today_start_datetime()->format('Y-m-d H:i:s');
    3772381    }
    378     /** Return a displayable stock quantity without double-counting parent and variation stock. */
     2382    /**
     2383     * Return stock quantity and WooCommerce availability without double-counting
     2384     * parent-managed and variation-managed inventory.
     2385     */
    3792386    private function stock_summary_for_product($product) {
    380         if (!$product) return ['managed' => false, 'quantity' => null];
     2387        if (!$product) {
     2388            return ['managed' => false, 'quantity' => null, 'in_stock' => false];
     2389        }
     2390
     2391        // WooCommerce's native availability API treats both instock and
     2392        // onbackorder as purchasable, and respects its stock-status filters.
     2393        $in_stock = (bool) $product->is_in_stock();
     2394
     2395        // When the parent manages stock, its quantity is authoritative.
     2396        // Do not add variation quantities to it.
    3812397        if ($product->managing_stock()) {
    3822398            $quantity = $product->get_stock_quantity();
    383             return ['managed' => true, 'quantity' => is_numeric($quantity) ? (float) $quantity : 0.0];
    384         }
     2399            return [
     2400                'managed' => true,
     2401                'quantity' => is_numeric($quantity) ? (float) $quantity : 0.0,
     2402                'in_stock' => $in_stock,
     2403            ];
     2404        }
     2405
     2406        // Otherwise, sum only variations that explicitly manage their own stock.
    3852407        if ($product->is_type('variable')) {
    3862408            $managed_any = false;
    … …  
    3882410            foreach ($product->get_children() as $variation_id) {
    3892411                $variation = wc_get_product($variation_id);
    390                 if (!$variation || !$variation->managing_stock()) continue;
     2412                if (!$variation || !$variation->managing_stock()) {
     2413                    continue;
     2414                }
    3912415                $managed_any = true;
    3922416                $quantity = $variation->get_stock_quantity();
    393                 if (is_numeric($quantity)) $total += (float) $quantity;
    394             }
    395             if ($managed_any) return ['managed' => true, 'quantity' => $total];
    396         }
    397         return ['managed' => false, 'quantity' => null];
     2417                if (is_numeric($quantity)) {
     2418                    $total += (float) $quantity;
     2419                }
     2420            }
     2421            if ($managed_any) {
     2422                return ['managed' => true, 'quantity' => $total, 'in_stock' => $in_stock];
     2423            }
     2424        }
     2425
     2426        return ['managed' => false, 'quantity' => null, 'in_stock' => $in_stock];
    3982427    }
    3992428
    … …  
    4112440        $placeholders = implode(',', array_fill(0, count($ids), '%d'));
    4122441        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all query values are prepared.
    413         $totals = $wpdb->get_results(
    414             $wpdb->prepare(
     2442        $totals = statpl_db_get_results(
     2443            statpl_db_prepare(
    4152444                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated; the IN placeholders are generated from the count of integer IDs, and every ID is passed to prepare() as a value.
    4162445                "SELECT product_id, total_views FROM {$this->views_table} WHERE product_id IN ($placeholders)",
    … …  
    4282457        $since = $this->today_start_mysql();
    4292458        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; all query values are prepared.
    430         $recent = $wpdb->get_results(
    431             $wpdb->prepare(
     2459        $recent = statpl_db_get_results(
     2460            statpl_db_prepare(
    4322461                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated; the date and every product ID are passed to prepare() as values. The IN placeholders are generated from the count of integer IDs.
    4332462                "SELECT product_id, COUNT(*) AS views_today FROM {$this->events_table} WHERE viewed_at >= %s AND product_id IN ($placeholders) GROUP BY product_id",
    … …  
    4712500        // Replace a snapshot atomically so readers never see a half-written order.
    4722501        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control is required for an atomic snapshot replacement and cannot be cached.
    473         if ($wpdb->query('START TRANSACTION') === false) return false;
     2502        if (statpl_db_query('START TRANSACTION') === false) return false;
    4742503        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This write replaces the stored order snapshot inside a transaction; it must execute immediately and must not be cached.
    475         $deleted = $wpdb->delete($this->order_items_table, ['order_id' => $order_id], ['%d']);
     2504        $deleted = statpl_db_delete($this->order_items_table, ['order_id' => $order_id], ['%d']);
    4762505        if ($deleted === false) {
    4772506            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction rollback is required after a failed write; caching is not applicable.
    478             $wpdb->query('ROLLBACK');
     2507            statpl_db_query('ROLLBACK');
    4792508            return false;
    4802509        }
    … …  
    5392568        foreach ($by_product as $pid => $row) {
    5402569            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- These rows are the authoritative refreshed order snapshot; writes must execute immediately and must not be cached.
    541             $inserted = $wpdb->insert($this->order_items_table, [
     2570            $inserted = statpl_db_insert($this->order_items_table, [
    5422571                'order_id'=>(int)$order_id, 'product_id'=>(int)$pid, 'order_status'=>$order->get_status(),
    5432572                'created_at'=>$created_at, 'paid_at'=>$paid_at, 'completed_at'=>$completed_at,
    … …  
    5472576            if ($inserted === false) {
    5482577                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction rollback is required after a failed write; caching is not applicable.
    549                 $wpdb->query('ROLLBACK');
     2578                statpl_db_query('ROLLBACK');
    5502579                return false;
    5512580            }
    5522581        }
    5532582        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction commit is required to persist the completed snapshot atomically.
    554         if ($wpdb->query('COMMIT') === false) {
    555             $wpdb->query('ROLLBACK');
     2583        if (statpl_db_query('COMMIT') === false) {
     2584            statpl_db_query('ROLLBACK');
    5562585            return false;
    5572586        }
    … …  
    5742603        global $wpdb;
    5752604        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Deleting a removed order snapshot is a required write; caching is not applicable.
    576         $wpdb->delete($this->order_items_table, ['order_id'=>absint($order_id)], ['%d']);
     2605        statpl_db_delete($this->order_items_table, ['order_id'=>absint($order_id)], ['%d']);
    5772606        delete_transient('statpl_order_stats_v1');
    5782607        delete_transient('statpl_global_summary_v2');
    5792608    }
     2609    /**
     2610     * Discover source-specific adapters registered by compatible analytics plugins.
     2611     * This registry is intentionally explicit: Statora Plus does not guess the
     2612     * meaning of arbitrary third-party database tables.
     2613     */
     2614    public function discover_historical_view_adapters() {
     2615        global $wpdb;
     2616
     2617        // Discovery mutates the persisted source/origin inventory and schedules
     2618        // snapshot/import work. Serialize it across concurrent WordPress requests
     2619        // so two provider scans cannot race while reconciling the same origins.
     2620        // Acquire the lock before clearing the request-local registry: a request
     2621        // that cannot enter discovery must not discard a still-valid registry.
     2622        $lock_name = 'statpl_generic_adapter_discovery';
     2623        $discovery_lock = $this->acquire_generic_advisory_lock($lock_name);
     2624        if (empty($discovery_lock['acquired'])) {
     2625            $status = isset($discovery_lock['status']) ? $discovery_lock['status'] : 'lock_unavailable';
     2626            if ($status !== 'lock_busy_or_unavailable') {
     2627                $this->log_generic_historical_scheduler_error(
     2628                    'adapter_discovery_lock',
     2629                    isset($discovery_lock['error']) ? $discovery_lock['error'] : $status
     2630                );
     2631            }
     2632            return ['status' => $status === 'lock_busy_or_unavailable' ? 'busy' : 'lock_unavailable', 'registered' => 0, 'rejected' => 0];
     2633        }
     2634
     2635        try {
     2636            if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2637                return ['status' => 'lock_session_lost', 'registered' => 0, 'rejected' => 0];
     2638            }
     2639            return $this->discover_historical_view_adapters_locked($discovery_lock);
     2640        } finally {
     2641            // Never attempt to release a connection-scoped lock through a replacement session.
     2642            $this->release_generic_advisory_lock($discovery_lock, 'adapter_discovery');
     2643        }
     2644    }
     2645
     2646    /** Perform provider discovery while holding the global discovery lock. */
     2647    private function discover_historical_view_adapters_locked($discovery_lock) {
     2648        // Invalidate request-local adapter objects before asking providers to
     2649        // rebuild the registry. If a provider throws, retaining objects from an
     2650        // earlier discovery pass could execute an adapter whose plugin is no
     2651        // longer available or whose registration is now failing. Persisted
     2652        // adapter identity/snapshot/checkpoint remain untouched in the database.
     2653        $this->historical_view_adapters = [];
     2654
     2655        // A third-party provider callback can throw while the registry filter is
     2656        // being assembled. Contain that failure here so it cannot interrupt
     2657        // Statora Plus initialization or the independent live-view tracker.
     2658        try {
     2659            $provided = apply_filters('statpl_historical_view_adapters', []);
     2660        } catch (Throwable $exception) {
     2661            if (defined('WP_DEBUG') && WP_DEBUG) {
     2662                $this->debug_log('Statora Plus: historical adapter registry filter failed: ' . $exception->getMessage());
     2663            }
     2664            return ['status' => 'provider_error', 'registered' => 0, 'rejected' => 1];
     2665        }
     2666        if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2667            return ['status' => 'lock_session_lost', 'registered' => 0, 'rejected' => 0];
     2668        }
     2669        if (!is_array($provided)) {
     2670            return ['status' => 'invalid_registry', 'registered' => 0, 'rejected' => 0];
     2671        }
     2672
     2673        // A successful discovery pass is authoritative; the registry was
     2674        // cleared before invoking third-party provider callbacks above.
     2675
     2676        $registered = 0;
     2677        $rejected = 0;
     2678        $seen_adapter_keys = [];
     2679        // A source_key is a persistent namespace, so two different adapter
     2680        // identities must not claim it during the same discovery pass—even if
     2681        // the first adapter currently reports no origins and therefore has no
     2682        // origin row from which the database conflict check could detect it.
     2683        $seen_source_keys = [];
     2684
     2685        foreach ($provided as $adapter) {
     2686            if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2687                return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected];
     2688            }
     2689            if (!is_object($adapter) || !$this->is_valid_historical_view_adapter($adapter)) {
     2690                $rejected++;
     2691                continue;
     2692            }
     2693
     2694            try {
     2695                $adapter_key = sanitize_key((string) $adapter->get_adapter_key());
     2696                $source_key = sanitize_key((string) $adapter->get_source_key());
     2697                $display_name = sanitize_text_field((string) $adapter->get_display_name());
     2698                $adapter_version = sanitize_text_field((string) $adapter->get_adapter_version());
     2699
     2700                // Adapter metadata methods are third-party callbacks. They may
     2701                // perform database work or trigger a reconnect; never continue
     2702                // discovery writes under an assumed connection after invoking them.
     2703                if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2704                    return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected];
     2705                }
     2706
     2707                if (
     2708                    $adapter_key === ''
     2709                    || $source_key === ''
     2710                    || $display_name === ''
     2711                    || $adapter_version === ''
     2712                    || strlen($adapter_key) > 100
     2713                    || strlen($source_key) > 100
     2714                    || strlen($display_name) > 191
     2715                    || strlen($adapter_version) > 32
     2716                    || isset($seen_adapter_keys[$adapter_key])
     2717                ) {
     2718                    $rejected++;
     2719                    continue;
     2720                }
     2721
     2722                // SlimStat already has a mature, separately tested import pipeline.
     2723                // It must not be registered a second time through this generic path.
     2724                if ($source_key === 'slimstat' || $adapter_key === 'slimstat') {
     2725                    continue;
     2726                }
     2727
     2728                if (isset($seen_source_keys[$source_key]) && $seen_source_keys[$source_key] !== $adapter_key) {
     2729                    $rejected++;
     2730                    if (defined('WP_DEBUG') && WP_DEBUG) {
     2731                        $this->debug_log('Statora Plus: rejected adapter "' . $adapter_key . '" because source_key "' . $source_key . '" was already claimed by another adapter in this discovery pass.');
     2732                    }
     2733                    continue;
     2734                }
     2735                $seen_source_keys[$source_key] = $adapter_key;
     2736
     2737                // A source_key is the persistent namespace for all of its origins.
     2738                // Do not allow a different adapter identity to append a second set
     2739                // of origins to an existing source, even when the origin_key values
     2740                // do not overlap. The discovery advisory lock makes this check and
     2741                // the subsequent registration one serialized discovery operation.
     2742                global $wpdb;
     2743                $conflicting_adapter_key = statpl_db_get_var(statpl_db_prepare(
     2744                    "SELECT origins.adapter_key
     2745                     FROM {$this->view_source_origins_table} AS origins
     2746                     INNER JOIN {$this->view_sources_table} AS sources
     2747                       ON sources.id = origins.source_id
     2748                     WHERE sources.source_key = %s
     2749                       AND (sources.adapter_key <> %s OR origins.adapter_key <> %s)
     2750                     LIMIT 1",
     2751                    $source_key,
     2752                    $adapter_key,
     2753                    $adapter_key
     2754                ));
     2755                if (!empty($wpdb->last_error)) {
     2756                    $rejected++;
     2757                    $this->log_generic_historical_scheduler_error(
     2758                        'adapter_identity_lookup',
     2759                        $wpdb->last_error
     2760                    );
     2761                    continue;
     2762                }
     2763                if ($conflicting_adapter_key !== null && $conflicting_adapter_key !== '') {
     2764                    $rejected++;
     2765                    if (defined('WP_DEBUG') && WP_DEBUG) {
     2766                        $this->debug_log('Statora Plus: rejected adapter "' . $adapter_key . '" because source_key "' . $source_key . '" is already bound to another adapter identity.');
     2767                    }
     2768                    continue;
     2769                }
     2770
     2771                $origins = $adapter->discover_origins();
     2772                if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2773                    return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected];
     2774                }
     2775                if (!is_array($origins)) {
     2776                    $rejected++;
     2777                    continue;
     2778                }
     2779
     2780                $valid_origins = [];
     2781                $seen_origin_keys = [];
     2782                $origin_declaration_invalid = false;
     2783                foreach ($origins as $origin) {
     2784                    if (!is_array($origin)) {
     2785                        $rejected++;
     2786                        $origin_declaration_invalid = true;
     2787                        continue;
     2788                    }
     2789
     2790                    $origin_key = isset($origin['origin_key'])
     2791                        ? sanitize_key((string) $origin['origin_key'])
     2792                        : '';
     2793                    $capabilities = isset($origin['capabilities']) && is_array($origin['capabilities'])
     2794                        ? $origin['capabilities']
     2795                        : [];
     2796                    $detection_status = isset($origin['detection_status'])
     2797                        ? (string) $origin['detection_status']
     2798                        : 'unknown';
     2799
     2800                    // Only explicit product-view capability qualifies as a source.
     2801                    if (
     2802                        $origin_key === ''
     2803                        || strlen($origin_key) > 100
     2804                        || isset($seen_origin_keys[$origin_key])
     2805                        || empty($capabilities['product_views'])
     2806                        || !in_array($detection_status, ['unknown', 'available', 'missing_table', 'incompatible_schema', 'database_error'], true)
     2807                    ) {
     2808                        $rejected++;
     2809                        $origin_declaration_invalid = true;
     2810                        continue;
     2811                    }
     2812
     2813                    // Sanitization can collapse distinct provider values to the
     2814                    // same key. Register each origin only once per discovery pass
     2815                    // so duplicate declarations cannot overwrite capabilities or
     2816                    // schedule duplicate snapshot/import initialization work.
     2817                    $seen_origin_keys[$origin_key] = true;
     2818                    $valid_origins[] = [
     2819                        'origin_key' => $origin_key,
     2820                        'capabilities' => $capabilities,
     2821                        'detection_status' => $detection_status,
     2822                    ];
     2823                }
     2824
     2825                if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2826                    return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected];
     2827                }
     2828                $source_result = $this->register_view_source(
     2829                    $source_key,
     2830                    $display_name,
     2831                    $adapter_version,
     2832                    'event',
     2833                    'detected',
     2834                    $adapter_key
     2835                );
     2836
     2837                if (empty($source_result['source_id'])) {
     2838                    $rejected++;
     2839                    continue;
     2840                }
     2841
     2842                // A successful discovery response is authoritative for the
     2843                // adapter's current origin inventory. If the provider returns a
     2844                // temporarily incomplete (or empty) list, preserve omitted
     2845                // origins and their cursors but take them out of active work.
     2846                // Do not infer omissions from a malformed/partially rejected
     2847                // declaration list, because that is not a trustworthy inventory.
     2848                $seen_adapter_keys[$adapter_key] = true;
     2849                if (!$origin_declaration_invalid) {
     2850                    $missing_origins = $this->mark_missing_generic_historical_origins_unavailable(
     2851                        $source_result['source_id'],
     2852                        $adapter_key,
     2853                        array_column($valid_origins, 'origin_key'),
     2854                        $discovery_lock
     2855                    );
     2856                    if (empty($missing_origins['status']) || $missing_origins['status'] !== 'updated') {
     2857                        // Do not register or schedule this adapter's newly reported
     2858                        // origins against a partially reconciled inventory. Keep the
     2859                        // previous persisted inventory intact and retry on the next
     2860                        // discovery pass after the database is healthy.
     2861                        $rejected++;
     2862                        $this->log_generic_historical_scheduler_error(
     2863                            'origin_inventory_reconciliation',
     2864                            isset($missing_origins['error']) ? $missing_origins['error'] : 'Origin inventory reconciliation did not commit.'
     2865                        );
     2866                        continue;
     2867                    }
     2868                }
     2869
     2870                if (!$valid_origins) {
     2871                    if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2872                        return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected];
     2873                    }
     2874                    $this->refresh_generic_historical_source_status($source_result['source_id']);
     2875                    continue;
     2876                }
     2877
     2878                $registered_origins = [];
     2879                $origin_registration_failures = 0;
     2880                $adapter_mismatch_detected = false;
     2881                foreach ($valid_origins as $origin) {
     2882                    if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2883                        return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected];
     2884                    }
     2885                    // Isolate each origin completely. A provider/database exception
     2886                    // while registering one origin must not prevent sibling origins
     2887                    // from being registered and scheduled in this discovery pass.
     2888                    try {
     2889                        $origin_result = $this->register_view_source_origin(
     2890                            $source_result['source_id'],
     2891                            $origin['origin_key'],
     2892                            $adapter_key,
     2893                            $origin['capabilities'],
     2894                            $origin['detection_status']
     2895                        );
     2896                    } catch (Throwable $exception) {
     2897                        $origin_result = [
     2898                            'status' => 'origin_exception',
     2899                            'error' => $exception->getMessage(),
     2900                        ];
     2901                        if (defined('WP_DEBUG') && WP_DEBUG) {
     2902                            $this->debug_log('Statora Plus: origin registration failed for "' . $origin['origin_key'] . '": ' . $exception->getMessage());
     2903                        }
     2904                    }
     2905
     2906                    if (!empty($origin_result['status']) && $origin_result['status'] === 'registered') {
     2907                        $registered_origins[] = $origin;
     2908                    } elseif (!empty($origin_result['status']) && $origin_result['status'] === 'adapter_mismatch') {
     2909                        $adapter_mismatch_detected = true;
     2910                        $origin_registration_failures++;
     2911                    } else {
     2912                        $origin_registration_failures++;
     2913                    }
     2914                }
     2915                if ($adapter_mismatch_detected) {
     2916                    // Preserve the established identity and cursor of the mismatched
     2917                    // origin, but do not let that isolated conflict block sibling
     2918                    // origins that were registered successfully with this adapter.
     2919                    $this->refresh_generic_historical_source_status($source_result['source_id']);
     2920                }
     2921
     2922                if ($registered_origins) {
     2923                    $this->historical_view_adapters[$adapter_key] = $adapter;
     2924                    $snapshot_result = $this->initialize_generic_historical_view_snapshots(
     2925                        $source_result['source_id'],
     2926                        $adapter_key,
     2927                        $registered_origins,
     2928                        $discovery_lock
     2929                    );
     2930                    if (($snapshot_result['status'] ?? '') === 'lock_session_lost'
     2931                        || !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2932                        return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected];
     2933                    }
     2934                    $this->refresh_generic_historical_source_status($source_result['source_id']);
     2935                    $registered++;
     2936                    if (!empty($snapshot_result['failed']) || $origin_registration_failures > 0) {
     2937                        $rejected++;
     2938                    }
     2939                    foreach ($registered_origins as $origin) {
     2940                        if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     2941                            return ['status' => 'lock_session_lost', 'registered' => $registered, 'rejected' => $rejected];
     2942                        }
     2943                        if ($origin['detection_status'] === 'available') {
     2944                            $this->schedule_generic_historical_view_import_batch(15, $source_result['source_id'], $origin['origin_key']);
     2945                        }
     2946                    }
     2947                } else {
     2948                    $rejected++;
     2949                }
     2950            } catch (Throwable $exception) {
     2951                $rejected++;
     2952                if (defined('WP_DEBUG') && WP_DEBUG) {
     2953                    $this->debug_log('Statora Plus: historical adapter discovery failed: ' . $exception->getMessage());
     2954                }
     2955            }
     2956        }
     2957
     2958        return [
     2959            'status' => 'completed',
     2960            'registered' => $registered,
     2961            'rejected' => $rejected,
     2962        ];
     2963    }
     2964
     2965    /** Persist snapshot and its initial checkpoint together, exactly once per origin. */
     2966    private function save_generic_historical_snapshot_if_empty($source_id, $origin_key, $snapshot, $checkpoint) {
     2967        global $wpdb;
     2968
     2969        $source_id = absint($source_id);
     2970        $origin_key = is_string($origin_key) ? trim($origin_key) : '';
     2971        if (!$source_id || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key)
     2972            || !is_array($snapshot) || empty($snapshot) || !is_array($checkpoint)) {
     2973            return ['status' => 'invalid_input'];
     2974        }
     2975
     2976        $snapshot_json = wp_json_encode($snapshot);
     2977        $checkpoint_json = wp_json_encode($checkpoint);
     2978        if (!is_string($snapshot_json) || !is_string($checkpoint_json)) {
     2979            return ['status' => 'encoding_error'];
     2980        }
     2981
     2982        $result = statpl_db_query(statpl_db_prepare(
     2983            "UPDATE {$this->view_source_origins_table}
     2984             SET snapshot_data = %s, checkpoint_data = %s, status = %s,
     2985                 started_at = %s, last_error = NULL, updated_at = %s
     2986             WHERE source_id = %d AND origin_key = %s
     2987               AND snapshot_data IS NULL AND checkpoint_data IS NULL",
     2988            $snapshot_json,
     2989            $checkpoint_json,
     2990            'snapshot_ready',
     2991            current_time('mysql'),
     2992            current_time('mysql'),
     2993            $source_id,
     2994            $origin_key
     2995        ));
     2996
     2997        if ($result === false || $wpdb->last_error !== '') {
     2998            return ['status' => 'database_error', 'error' => $wpdb->last_error];
     2999        }
     3000        if ($result === 1) {
     3001            return ['status' => 'updated', 'source_id' => $source_id, 'origin_key' => $origin_key];
     3002        }
     3003
     3004        $existing = $this->get_view_source_origin_state($source_id, $origin_key);
     3005        if ($existing['status'] !== 'found') {
     3006            return $existing;
     3007        }
     3008        if (is_array($existing['snapshot']) && !empty($existing['snapshot'])
     3009            && is_array($existing['checkpoint'])) {
     3010            return [
     3011                'status' => 'already_initialized',
     3012                'source_id' => $source_id,
     3013                'origin_key' => $origin_key,
     3014                'snapshot' => $existing['snapshot'],
     3015                'checkpoint' => $existing['checkpoint'],
     3016            ];
     3017        }
     3018        return ['status' => 'initialization_conflict', 'source_id' => $source_id, 'origin_key' => $origin_key];
     3019    }
     3020
     3021    /**
     3022     * Capture an immutable upper-bound snapshot for every detected generic origin.
     3023     * Existing snapshots are never overwritten, so retries/re-discovery cannot move
     3024     * the historical import boundary forward and accidentally include live traffic.
     3025     */
     3026    private function initialize_generic_historical_view_snapshots($source_id, $adapter_key, $origins, $discovery_lock = null) {
     3027        $adapter = $this->get_historical_view_adapter($adapter_key);
     3028        if (!$adapter) {
     3029            return ['status' => 'adapter_unavailable', 'failed' => 1];
     3030        }
     3031
     3032        $initialized = 0;
     3033        $skipped = 0;
     3034        $failed = 0;
     3035
     3036        foreach ($origins as $origin) {
     3037            if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3038                return [
     3039                    'status' => 'lock_session_lost',
     3040                    'initialized' => $initialized,
     3041                    'skipped' => $skipped,
     3042                    'failed' => $failed,
     3043                ];
     3044            }
     3045            if (!is_array($origin) || empty($origin['origin_key'])) {
     3046                $failed++;
     3047                continue;
     3048            }
     3049            if (($origin['detection_status'] ?? '') !== 'available') {
     3050                $skipped++;
     3051                continue;
     3052            }
     3053
     3054            $state = $this->get_view_source_origin_state($source_id, $origin['origin_key']);
     3055            if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3056                return [
     3057                    'status' => 'lock_session_lost',
     3058                    'initialized' => $initialized,
     3059                    'skipped' => $skipped,
     3060                    'failed' => $failed,
     3061                ];
     3062            }
     3063            if (empty($state['status']) || $state['status'] !== 'found') {
     3064                $failed++;
     3065                $failure = $this->record_generic_historical_view_failure($source_id,
     3066                    $origin['origin_key'],
     3067                    'snapshot_initialization',
     3068                    0,
     3069                    'Could not read the stored origin state while initializing its historical snapshot.',
     3070                    null,
     3071                    8,
     3072                    $discovery_lock);
     3073                if (!$failure['blocked']) {
     3074                    $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']);
     3075                }
     3076                continue;
     3077            }
     3078            if (is_array($state['snapshot']) && !empty($state['snapshot'])) {
     3079                // A persisted snapshot is already valid; clear any stale
     3080                // initialization failure only while this discovery worker still
     3081                // owns the lock that serializes snapshot-ledger changes.
     3082                if (!$this->clear_generic_historical_view_failure($source_id, $origin['origin_key'], 'snapshot_initialization', $discovery_lock)) {
     3083                    $failed++;
     3084                    $this->schedule_generic_historical_snapshot_retry(60, $source_id, $origin['origin_key']);
     3085                    continue;
     3086                }
     3087                $skipped++;
     3088                continue;
     3089            }
     3090
     3091            try {
     3092                $snapshot = $adapter->get_origin_snapshot($origin['origin_key']);
     3093                if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3094                    return [
     3095                        'status' => 'lock_session_lost',
     3096                        'initialized' => $initialized,
     3097                        'skipped' => $skipped,
     3098                        'failed' => $failed,
     3099                    ];
     3100                }
     3101                if (!is_array($snapshot) || empty($snapshot)) {
     3102                    $failed++;
     3103                    if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3104                        return [
     3105                            'status' => 'lock_session_lost',
     3106                            'initialized' => $initialized,
     3107                            'skipped' => $skipped,
     3108                            'failed' => $failed,
     3109                        ];
     3110                    }
     3111                    $failure = $this->record_generic_historical_view_failure($source_id,
     3112                    $origin['origin_key'],
     3113                    'snapshot_initialization',
     3114                    0,
     3115                    'Adapter returned an empty or invalid historical snapshot.',
     3116                    null,
     3117                    8,
     3118                    $discovery_lock);
     3119                    if (!$failure['blocked']) {
     3120                        $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']);
     3121                    }
     3122                    continue;
     3123                }
     3124
     3125                // Adapter snapshots must declare their own stable boundary. The core
     3126                // records the capture time but does not guess source-specific cursors.
     3127                if (!isset($snapshot['captured_at'])) {
     3128                    $snapshot['captured_at'] = current_time('mysql');
     3129                }
     3130                if (!isset($snapshot['boundary']) || !is_array($snapshot['boundary']) || empty($snapshot['boundary'])) {
     3131                    $failed++;
     3132                    if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3133                        return [
     3134                            'status' => 'lock_session_lost',
     3135                            'initialized' => $initialized,
     3136                            'skipped' => $skipped,
     3137                            'failed' => $failed,
     3138                        ];
     3139                    }
     3140                    $failure = $this->record_generic_historical_view_failure($source_id,
     3141                    $origin['origin_key'],
     3142                    'snapshot_initialization',
     3143                    0,
     3144                    'Adapter snapshot did not provide the required immutable boundary descriptor.',
     3145                    null,
     3146                    8,
     3147                    $discovery_lock);
     3148                    if (!$failure['blocked']) {
     3149                        $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']);
     3150                    }
     3151                    continue;
     3152                }
     3153
     3154                $initial_checkpoint = array_key_exists('initial_checkpoint', $snapshot)
     3155                    ? $snapshot['initial_checkpoint']
     3156                    : null;
     3157                // A null initial cursor is valid (many sources begin at the start
     3158                // of their history). Non-null cursors must be JSON-safe scalar/array
     3159                // values within the same storage limit used for subsequent cursors.
     3160                if ($initial_checkpoint !== null && !$this->is_valid_generic_historical_cursor($initial_checkpoint)) {
     3161                    $failed++;
     3162                    if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3163                        return [
     3164                            'status' => 'lock_session_lost',
     3165                            'initialized' => $initialized,
     3166                            'skipped' => $skipped,
     3167                            'failed' => $failed,
     3168                        ];
     3169                    }
     3170                    $failure = $this->record_generic_historical_view_failure($source_id,
     3171                    $origin['origin_key'],
     3172                    'snapshot_initialization',
     3173                    0,
     3174                    'Adapter snapshot contained an invalid initial checkpoint. Expected null or a JSON-safe scalar/array cursor no larger than 60 KB.',
     3175                    null,
     3176                    8,
     3177                    $discovery_lock);
     3178                    if (!$failure['blocked']) {
     3179                        $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']);
     3180                    }
     3181                    continue;
     3182                }
     3183
     3184                $checkpoint = [
     3185                    'cursor' => $initial_checkpoint,
     3186                    'processed' => 0,
     3187                    'snapshot_captured_at' => $snapshot['captured_at'],
     3188                ];
     3189                unset($snapshot['initial_checkpoint']);
     3190
     3191                if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3192                    return [
     3193                        'status' => 'lock_session_lost',
     3194                        'initialized' => $initialized,
     3195                        'skipped' => $skipped,
     3196                        'failed' => $failed,
     3197                    ];
     3198                }
     3199                $saved = $this->save_generic_historical_snapshot_if_empty(
     3200                    $source_id,
     3201                    $origin['origin_key'],
     3202                    $snapshot,
     3203                    $checkpoint
     3204                );
     3205                if (empty($saved['status']) || !in_array($saved['status'], ['updated', 'already_initialized'], true)) {
     3206                    $failed++;
     3207                    if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3208                        return [
     3209                            'status' => 'lock_session_lost',
     3210                            'initialized' => $initialized,
     3211                            'skipped' => $skipped,
     3212                            'failed' => $failed,
     3213                        ];
     3214                    }
     3215                    $failure = $this->record_generic_historical_view_failure($source_id,
     3216                    $origin['origin_key'],
     3217                    'snapshot_initialization',
     3218                    0,
     3219                    'Could not persist the immutable snapshot and initial checkpoint: ' . (isset($saved['status']) ? $saved['status'] : 'unknown error'),
     3220                    null,
     3221                    8,
     3222                    $discovery_lock);
     3223                    if (!$failure['blocked']) {
     3224                        $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']);
     3225                    }
     3226                    continue;
     3227                }
     3228                if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3229                    return [
     3230                        'status' => 'lock_session_lost',
     3231                        'initialized' => $initialized,
     3232                        'skipped' => $skipped,
     3233                        'failed' => $failed,
     3234                    ];
     3235                }
     3236                $failure_cleared = $this->clear_generic_historical_view_failure($source_id, $origin['origin_key'], 'snapshot_initialization', $discovery_lock);
     3237                if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3238                    return [
     3239                        'status' => 'lock_session_lost',
     3240                        'initialized' => $initialized,
     3241                        'skipped' => $skipped,
     3242                        'failed' => $failed,
     3243                    ];
     3244                }
     3245                if (!$failure_cleared) {
     3246                    $failed++;
     3247                    $this->schedule_generic_historical_snapshot_retry(60, $source_id, $origin['origin_key']);
     3248                    continue;
     3249                }
     3250                if ($saved['status'] === 'updated') {
     3251                    $initialized++;
     3252                } else {
     3253                    $skipped++;
     3254                }
     3255            } catch (Throwable $exception) {
     3256                if (is_array($discovery_lock) && !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3257                    return [
     3258                        'status' => 'lock_session_lost',
     3259                        'initialized' => $initialized,
     3260                        'skipped' => $skipped,
     3261                        'failed' => $failed,
     3262                    ];
     3263                }
     3264                $failed++;
     3265                $failure = $this->record_generic_historical_view_failure($source_id,
     3266                    $origin['origin_key'],
     3267                    'snapshot_initialization',
     3268                    0,
     3269                    $exception->getMessage(),
     3270                    null,
     3271                    8,
     3272                    $discovery_lock);
     3273                if (!$failure['blocked']) {
     3274                    $this->schedule_generic_historical_snapshot_retry($failure['retry_delay'], $source_id, $origin['origin_key']);
     3275                }
     3276                if (defined('WP_DEBUG') && WP_DEBUG) {
     3277                    $this->debug_log('Statora Plus: historical origin snapshot failed: ' . $exception->getMessage());
     3278                }
     3279            }
     3280        }
     3281
     3282        return [
     3283            'status' => $failed ? 'partial' : 'completed',
     3284            'initialized' => $initialized,
     3285            'skipped' => $skipped,
     3286            'failed' => $failed,
     3287        ];
     3288    }
     3289
     3290    /**
     3291     * Preserve origins omitted by a successful adapter discovery pass without
     3292     * allowing stale origins to continue importing. Their immutable adapter
     3293     * identity, snapshot, checkpoint, events and failure ledger are untouched.
     3294     */
     3295    private function mark_missing_generic_historical_origins_unavailable($source_id, $adapter_key, $discovered_origin_keys, $discovery_lock = null) {
     3296        global $wpdb;
     3297
     3298        $source_id = absint($source_id);
     3299        $adapter_key = sanitize_key((string) $adapter_key);
     3300        if (!$source_id || $adapter_key === '' || !is_array($discovered_origin_keys)) {
     3301            return ['status' => 'invalid_input', 'updated' => 0];
     3302        }
     3303
     3304        $discovered = [];
     3305        foreach ($discovered_origin_keys as $origin_key) {
     3306            $origin_key = sanitize_key((string) $origin_key);
     3307            if ($origin_key !== '') {
     3308                $discovered[$origin_key] = true;
     3309            }
     3310        }
     3311
     3312        if (!is_array($discovery_lock) || !$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3313            return ['status' => 'lock_session_lost', 'updated' => 0, 'error' => 'Discovery lock session is no longer current.'];
     3314        }
     3315
     3316        $transaction_started = false;
     3317        $transaction_committed = false;
     3318        try {
     3319            // Reconciliation is one logical inventory operation. Keep it atomic so
     3320            // a database error halfway through cannot leave only part of the omitted
     3321            // origins marked unavailable. A later discovery can safely retry it.
     3322            $begin_result = statpl_db_query('START TRANSACTION');
     3323            if ($begin_result === false || $wpdb->last_error !== '') {
     3324                return ['status' => 'database_error', 'updated' => 0, 'error' => $wpdb->last_error];
     3325            }
     3326            $transaction_started = true;
     3327
     3328            if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3329                return ['status' => 'lock_session_lost', 'updated' => 0, 'error' => 'Discovery lock session changed before origin inventory read.'];
     3330            }
     3331            $existing = statpl_db_get_results(
     3332                statpl_db_prepare(
     3333                    "SELECT origin_key, status
     3334                     FROM {$this->view_source_origins_table}
     3335                     WHERE source_id = %d AND adapter_key = %s
     3336                     FOR UPDATE",
     3337                    $source_id,
     3338                    $adapter_key
     3339                ),
     3340                ARRAY_A
     3341            );
     3342            if (!empty($wpdb->last_error) || !is_array($existing)) {
     3343                return ['status' => 'database_error', 'updated' => 0, 'error' => $wpdb->last_error !== '' ? $wpdb->last_error : 'Origin inventory query returned an invalid result.'];
     3344            }
     3345
     3346            $updated = 0;
     3347            foreach ($existing as $row) {
     3348                if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3349                    return ['status' => 'lock_session_lost', 'updated' => 0, 'error' => 'Discovery lock session changed during origin inventory reconciliation.'];
     3350                }
     3351                $origin_key = isset($row['origin_key']) ? (string) $row['origin_key'] : '';
     3352                if ($origin_key === '' || isset($discovered[$origin_key])) {
     3353                    continue;
     3354                }
     3355
     3356                // Terminal lifecycle states are retained. Detection becomes unknown
     3357                // until the provider declares this origin again; active work becomes
     3358                // unavailable so stale adapter inventories cannot process it.
     3359                $updates = [
     3360                    'detection_status' => 'unknown',
     3361                    'updated_at' => current_time('mysql'),
     3362                ];
     3363                $formats = ['%s', '%s'];
     3364                if (in_array($row['status'] ?? '', ['pending', 'snapshot_ready', 'importing', 'retry_wait'], true)) {
     3365                    $updates['status'] = 'unavailable';
     3366                    $updates['last_error'] = 'This origin was not declared by the adapter during the latest successful discovery pass; its saved snapshot and checkpoint were preserved.';
     3367                    $formats = ['%s', '%s', '%s', '%s'];
     3368                }
     3369
     3370                $result = statpl_db_update(
     3371                    $this->view_source_origins_table,
     3372                    $updates,
     3373                    ['source_id' => $source_id, 'origin_key' => $origin_key],
     3374                    $formats,
     3375                    ['%d', '%s']
     3376                );
     3377                if ($result === false || $wpdb->last_error !== '') {
     3378                    return ['status' => 'database_error', 'updated' => 0, 'error' => $wpdb->last_error !== '' ? $wpdb->last_error : 'Origin reconciliation update failed.'];
     3379                }
     3380                $updated++;
     3381            }
     3382
     3383            if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3384                return ['status' => 'lock_session_lost', 'updated' => 0, 'error' => 'Discovery lock session changed before origin inventory commit.'];
     3385            }
     3386            $commit_result = statpl_db_query('COMMIT');
     3387            if ($commit_result === false || $wpdb->last_error !== '') {
     3388                return ['status' => 'database_error', 'updated' => 0, 'error' => $wpdb->last_error !== '' ? $wpdb->last_error : 'Origin reconciliation commit failed.'];
     3389            }
     3390            $transaction_committed = true;
     3391            if (!$this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3392                return ['status' => 'commit_outcome_unknown', 'updated' => 0, 'error' => 'Origin inventory commit completed but discovery lock session could not be confirmed.'];
     3393            }
     3394            return ['status' => 'updated', 'updated' => $updated];
     3395        } catch (Throwable $exception) {
     3396            return ['status' => 'database_error', 'updated' => 0, 'error' => $exception->getMessage()];
     3397        } finally {
     3398            // Includes thrown exceptions and ambiguous failures before COMMIT.
     3399            // A rollback after a successful commit is intentionally avoided.
     3400            if ($transaction_started && !$transaction_committed && $this->generic_advisory_lock_session_is_current($discovery_lock)) {
     3401                try {
     3402                    statpl_db_query('ROLLBACK');
     3403                } catch (Throwable $rollback_exception) {
     3404                    $this->log_generic_historical_scheduler_error('origin_inventory_rollback', $rollback_exception->getMessage());
     3405                }
     3406            }
     3407        }
     3408    }
     3409
     3410    /** Validate the minimum public adapter contract without trusting a provider. */
     3411    private function is_valid_historical_view_adapter($adapter) {
     3412        foreach ([
     3413            'get_adapter_key',
     3414            'get_source_key',
     3415            'get_display_name',
     3416            'get_adapter_version',
     3417            'discover_origins',
     3418            'get_origin_snapshot',
     3419            'get_product_view_batch',
     3420            'normalize_record',
     3421        ] as $method) {
     3422            if (!is_callable([$adapter, $method])) {
     3423                return false;
     3424            }
     3425        }
     3426
     3427        return true;
     3428    }
     3429
     3430    /**
     3431     * Recompute the persisted status of one generic source from all its origins.
     3432     * This is intentionally called only by the generic pipeline; the independent
     3433     * SlimStat lifecycle and its status model are left untouched.
     3434     */
     3435    private function refresh_generic_historical_source_status($source_id) {
     3436        global $wpdb;
     3437
     3438        $source_id = absint($source_id);
     3439        if (!$source_id) {
     3440            return ['status' => 'invalid_source'];
     3441        }
     3442
     3443        // Serialize aggregate status calculation and persistence per source.
     3444        // Use the shared lock contract so the owning connection is captured
     3445        // atomically and RELEASE_LOCK is never attempted on a replacement session.
     3446        $status_lock_name = 'statpl_status_' . substr(md5((string) $source_id), 0, 32);
     3447        $status_lock = $this->acquire_generic_advisory_lock($status_lock_name);
     3448        if (empty($status_lock['acquired'])) {
     3449            if (($status_lock['status'] ?? '') === 'lock_busy_or_unavailable' && empty($status_lock['error'])) {
     3450                return ['status' => 'status_lock_busy'];
     3451            }
     3452            return ['status' => 'database_error', 'error' => (string) ($status_lock['error'] ?? 'Unable to acquire source status lock.')];
     3453        }
     3454
     3455        try {
     3456        if (!$this->generic_advisory_lock_session_is_current($status_lock)) {
     3457            return ['status' => 'status_lock_lost'];
     3458        }
     3459        $origins = statpl_db_get_results(statpl_db_prepare(
     3460            "SELECT detection_status, status FROM {$this->view_source_origins_table} WHERE source_id = %d",
     3461            $source_id
     3462        ), ARRAY_A);
     3463        if (!empty($wpdb->last_error)) {
     3464            return ['status' => 'database_error', 'error' => $wpdb->last_error];
     3465        }
     3466        if (!$this->generic_advisory_lock_session_is_current($status_lock)) {
     3467            return ['status' => 'status_lock_lost'];
     3468        }
     3469        if (!is_array($origins) || !$origins) {
     3470            return ['status' => 'no_origins'];
     3471        }
     3472
     3473        $available_origins = array_values(array_filter($origins, function ($origin) {
     3474            // Detection describes what the provider reported on its last pass;
     3475            // lifecycle status additionally records whether this exact persisted
     3476            // adapter identity is currently safe to execute. A replacement
     3477            // adapter can be detected as available while its origin remains
     3478            // deliberately unavailable because it cannot inherit the old cursor.
     3479            return isset($origin['detection_status'], $origin['status'])
     3480                && $origin['detection_status'] === 'available'
     3481                && $origin['status'] !== 'unavailable';
     3482        }));
     3483        $available = count($available_origins);
     3484        $available_completed = count(array_filter($available_origins, function ($origin) {
     3485            return isset($origin['status']) && $origin['status'] === 'completed';
     3486        }));
     3487        // Only available origins participate in import lifecycle aggregation.
     3488        // An unavailable/detected sibling must never keep an otherwise finished
     3489        // source stuck in importing or retry_wait.
     3490        $available_statuses = array_column($available_origins, 'status');
     3491
     3492        // A source remains actively importing while ANY available origin is
     3493        // still progressing. A blocked sibling origin must not make the whole
     3494        // source appear terminal before the remaining origins finish.
     3495        if (in_array('importing', $available_statuses, true) || in_array('snapshot_ready', $available_statuses, true) || in_array('pending', $available_statuses, true)) {
     3496            $source_status = 'importing';
     3497        } elseif (in_array('retry_wait', $available_statuses, true)) {
     3498            $source_status = 'retry_wait';
     3499        } elseif (in_array('needs_attention', $available_statuses, true)) {
     3500            $source_status = 'needs_attention';
     3501        } elseif ($available === 0) {
     3502            $source_status = 'unavailable';
     3503        } elseif ($available_completed === $available) {
     3504            // A source with unavailable origins is only partial, never fully complete.
     3505            $source_status = ($available === count($origins)) ? 'completed' : 'partial';
     3506        } elseif (in_array('completed', $available_statuses, true)) {
     3507            $source_status = 'partial';
     3508        } else {
     3509            $source_status = 'detected';
     3510        }
     3511
     3512        // Status reconciliation is not a source-registration/content update.
     3513        // Keep updated_at reserved for genuine source changes; otherwise the
     3514        // recovery scanner would generate needless writes on every request.
     3515        if (!$this->generic_advisory_lock_session_is_current($status_lock)) {
     3516            return ['status' => 'status_lock_lost'];
     3517        }
     3518        // Bind source-status persistence to the same MySQL session that owns
     3519        // this status lock. A PHP-side check alone leaves a reconnect race between
     3520        // validation and UPDATE; the SQL predicate closes that window.
     3521        if (empty($status_lock['lock_name']) || !$this->generic_advisory_lock_session_is_current($status_lock)) {
     3522            return ['status' => 'status_lock_lost'];
     3523        }
     3524        $updated = statpl_db_query(statpl_db_prepare(
     3525            "UPDATE {$this->view_sources_table}
     3526             SET status = %s
     3527             WHERE id = %d AND IS_USED_LOCK(%s) = CONNECTION_ID()",
     3528            $source_status,
     3529            $source_id,
     3530            (string) $status_lock['lock_name']
     3531        ));
     3532        if ($updated === false || $wpdb->last_error !== '') {
     3533            return ['status' => 'database_error', 'error' => $wpdb->last_error];
     3534        }
     3535        if (!$this->generic_advisory_lock_session_is_current($status_lock)) {
     3536            return ['status' => 'status_lock_lost'];
     3537        }
     3538
     3539        // Do not treat an accepted UPDATE as durable source-state convergence.
     3540        // The row may have been unchanged (0 affected rows), or an ambiguous
     3541        // acknowledgement may have occurred. Read the aggregate status back and
     3542        // require an exact match before reporting success to callers.
     3543        $persisted_status = statpl_db_get_var(statpl_db_prepare(
     3544            "SELECT status FROM {$this->view_sources_table} WHERE id = %d",
     3545            $source_id
     3546        ));
     3547        if ($wpdb->last_error !== '') {
     3548            return ['status' => 'database_error', 'error' => $wpdb->last_error];
     3549        }
     3550        if (!$this->generic_advisory_lock_session_is_current($status_lock)) {
     3551            return ['status' => 'status_lock_lost'];
     3552        }
     3553        if ($persisted_status === null) {
     3554            // The source may have been removed after the bounded scan selected it.
     3555            // Treat that as a vanished work item, not as a database failure.
     3556            return ['status' => 'source_missing'];
     3557        }
     3558        if (!is_string($persisted_status) || $persisted_status !== $source_status) {
     3559            return [
     3560                'status' => 'state_unverified',
     3561                'expected_status' => $source_status,
     3562                'persisted_status' => is_string($persisted_status) ? $persisted_status : null,
     3563            ];
     3564        }
     3565
     3566        return ['status' => 'updated', 'source_status' => $source_status];
     3567        } finally {
     3568            // Release only if the original connection still owns this lock.
     3569            $this->release_generic_advisory_lock($status_lock, 'source_status_reconciliation');
     3570        }
     3571    }
     3572
     3573    /** Retry adapter discovery and snapshot initialization after a bounded delay. */
     3574    public function retry_generic_historical_view_snapshot($source_id = 0, $origin_key = '') {
     3575        $source_id = absint($source_id);
     3576        $origin_key = sanitize_key((string) $origin_key);
     3577        if (!$source_id || $origin_key === '') {
     3578            return ['status' => 'invalid_input'];
     3579        }
     3580
     3581        // Discovery re-resolves the provider in this request and retries only origins
     3582        // whose immutable snapshot has not yet been persisted. Existing snapshots are
     3583        // protected by save_generic_historical_snapshot_if_empty().
     3584        $result = $this->discover_historical_view_adapters();
     3585        $state = $this->get_view_source_origin_state($source_id, $origin_key);
     3586        if (!empty($state['status']) && $state['status'] === 'found'
     3587            && is_array($state['snapshot']) && !empty($state['snapshot'])) {
     3588            // The discovery worker owns the snapshot failure ledger lifecycle. This
     3589            // follow-up request has no discovery lock context, so it must not delete
     3590            // the ledger row outside that ownership boundary.
     3591            $this->schedule_generic_historical_view_import_batch(15, $source_id, $origin_key);
     3592            $this->refresh_generic_historical_source_status($source_id);
     3593            return ['status' => 'snapshot_ready', 'discovery' => $result];
     3594        }
     3595
     3596        $this->refresh_generic_historical_source_status($source_id);
     3597        return ['status' => 'snapshot_still_unavailable', 'discovery' => $result];
     3598    }
     3599
     3600    /** Schedule bounded retries for transient snapshot/provider initialization failures. */
     3601    private function schedule_generic_historical_snapshot_retry($delay, $source_id, $origin_key) {
     3602        $args = [absint($source_id), sanitize_key((string) $origin_key)];
     3603        $hook = 'statpl_generic_view_snapshot_retry';
     3604        $requested_timestamp = time() + max(1, (int) $delay);
     3605
     3606        if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) {
     3607            $existing_timestamp = false;
     3608            if (function_exists('as_next_scheduled_action')) {
     3609                try {
     3610                    $existing_timestamp = as_next_scheduled_action($hook, $args, 'statpl-plus');
     3611                } catch (Throwable $exception) {
     3612                    $this->log_generic_historical_scheduler_error($hook . '_lookup', $exception->getMessage());
     3613                }
     3614            }
     3615
     3616            // A newly discovered transient failure may have a shorter backoff than
     3617            // an older snapshot retry. Keep the earliest deadline, not merely any
     3618            // existing action, so stale long delays cannot postpone recovery.
     3619            if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) {
     3620                return true;
     3621            }
     3622            if ($existing_timestamp && function_exists('as_unschedule_all_actions')) {
     3623                try {
     3624                    as_unschedule_all_actions($hook, $args, 'statpl-plus');
     3625                } catch (Throwable $exception) {
     3626                    $this->log_generic_historical_scheduler_error($hook . '_unschedule', $exception->getMessage());
     3627                    return true;
     3628                }
     3629            } elseif ($existing_timestamp) {
     3630                return true;
     3631            } elseif (function_exists('as_has_scheduled_action')) {
     3632                try {
     3633                    if (as_has_scheduled_action($hook, $args, 'statpl-plus')) {
     3634                        // Without a reliable timestamp/removal API, retain the
     3635                        // existing action rather than creating an uncontrolled duplicate.
     3636                        return true;
     3637                    }
     3638                } catch (Throwable $exception) {
     3639                    $this->log_generic_historical_scheduler_error($hook . '_presence_check', $exception->getMessage());
     3640                }
     3641            }
     3642
     3643            try {
     3644                $action_id = as_schedule_single_action($requested_timestamp, $hook, $args, 'statpl-plus');
     3645                if ($action_id) {
     3646                    return true;
     3647                }
     3648            } catch (Throwable $exception) {
     3649                $this->log_generic_historical_scheduler_error($hook, $exception->getMessage());
     3650            }
     3651            // Action Scheduler can be unavailable or fail transiently. Fall back to WP-Cron.
     3652        }
     3653
     3654        // WP-Cron follows the same earliest-deadline rule. If replacing a later
     3655        // event fails, retain it rather than creating a duplicate with uncertain order.
     3656        try {
     3657            $existing_timestamp = wp_next_scheduled($hook, $args);
     3658            if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) {
     3659                return true;
     3660            }
     3661            if ($existing_timestamp) {
     3662                $unscheduled = wp_unschedule_event((int) $existing_timestamp, $hook, $args);
     3663                if (!$unscheduled || is_wp_error($unscheduled)) {
     3664                    $this->log_generic_historical_scheduler_error(
     3665                        $hook . '_unschedule',
     3666                        is_wp_error($unscheduled) ? $unscheduled->get_error_message() : 'Could not replace the later WP-Cron event.'
     3667                    );
     3668                    return true;
     3669                }
     3670            }
     3671            $scheduled = wp_schedule_single_event($requested_timestamp, $hook, $args);
     3672            if (!$scheduled || is_wp_error($scheduled)) {
     3673                $this->log_generic_historical_scheduler_error($hook, is_wp_error($scheduled) ? $scheduled->get_error_message() : 'WP-Cron rejected the event.');
     3674                return false;
     3675            }
     3676            return true;
     3677        } catch (Throwable $exception) {
     3678            $this->log_generic_historical_scheduler_error($hook, $exception->getMessage());
     3679            return false;
     3680        }
     3681    }
     3682
     3683    /** Requeue persisted generic-import work after a previous scheduler outage. */
     3684    private function recover_unscheduled_generic_historical_work() {
     3685        global $wpdb;
     3686
     3687        $origins = statpl_db_get_results(
     3688            "SELECT source_id, origin_key, snapshot_data, status, detection_status
     3689             FROM {$this->view_source_origins_table}
     3690             WHERE detection_status = 'available'
     3691               AND status IN ('pending', 'snapshot_ready', 'importing', 'retry_wait')
     3692             ORDER BY updated_at ASC
     3693             LIMIT 50",
     3694            ARRAY_A
     3695        );
     3696        if (!is_array($origins) || !empty($wpdb->last_error)) {
     3697            $this->log_generic_historical_scheduler_error('recovery_scan', !empty($wpdb->last_error) ? $wpdb->last_error : 'Unable to read pending origins.');
     3698            return 0;
     3699        }
     3700
     3701        $requeued = 0;
     3702        foreach ($origins as $origin) {
     3703            $source_id = absint($origin['source_id'] ?? 0);
     3704            $origin_key = sanitize_key((string) ($origin['origin_key'] ?? ''));
     3705            if (!$source_id || $origin_key === '') {
     3706                continue;
     3707            }
     3708
     3709            if (empty($origin['snapshot_data'])) {
     3710                $scheduled = $this->schedule_generic_historical_snapshot_retry(300, $source_id, $origin_key);
     3711            } else {
     3712                $scheduled = $this->schedule_generic_historical_view_import_batch(300, $source_id, $origin_key);
     3713            }
     3714            if ($scheduled) {
     3715                $requeued++;
     3716            }
     3717        }
     3718
     3719        // Retry rows are durable in the failure ledger. Recreate their worker
     3720        // trigger independently of the main import cursor.
     3721        $retry_origins = statpl_db_get_results(
     3722            "SELECT source_id, origin_key, MIN(next_retry_at) AS next_retry_at
     3723             FROM {$this->view_source_failures_table}
     3724             WHERE status = 'retry'
     3725               AND retry_checkpoint IS NOT NULL
     3726               AND retry_checkpoint <> ''
     3727             GROUP BY source_id, origin_key
     3728             ORDER BY MIN(next_retry_at) ASC
     3729             LIMIT 50",
     3730            ARRAY_A
     3731        );
     3732        if (is_array($retry_origins) && empty($wpdb->last_error)) {
     3733            foreach ($retry_origins as $retry_origin) {
     3734                $source_id = absint($retry_origin['source_id'] ?? 0);
     3735                $origin_key = sanitize_key((string) ($retry_origin['origin_key'] ?? ''));
     3736                if (!$source_id || $origin_key === '') {
     3737                    continue;
     3738                }
     3739                $next_timestamp = !empty($retry_origin['next_retry_at']) ? strtotime((string) $retry_origin['next_retry_at']) : false;
     3740                $delay = $next_timestamp !== false ? max(1, $next_timestamp - current_time('timestamp')) : 300;
     3741                if ($this->schedule_generic_historical_failure_retry(min(86400, $delay), $source_id, $origin_key)) {
     3742                    $requeued++;
     3743                }
     3744            }
     3745        } elseif (!empty($wpdb->last_error)) {
     3746            $this->log_generic_historical_scheduler_error('retry_recovery_scan', $wpdb->last_error);
     3747        }
     3748
     3749        // Serialize the cursor read, bounded page, and cursor write. Per-source
     3750        // locks protect each aggregate update; this scan-level lock additionally
     3751        // prevents concurrent requests from moving the persistent cursor backwards.
     3752        // Use the shared lock helper so acquisition, ownership checks and release
     3753        // follow the same connection-identity contract as the other workers.
     3754        $scan_lock_name = 'statpl_srcscan_' . substr(md5((string) $wpdb->prefix), 0, 32);
     3755        $scan_lock = $this->acquire_generic_advisory_lock($scan_lock_name);
     3756        if (empty($scan_lock['acquired'])) {
     3757            $scan_status = sanitize_key((string) ($scan_lock['status'] ?? 'lock_unavailable'));
     3758            if ($scan_status !== 'lock_busy_or_unavailable') {
     3759                $this->log_generic_historical_scheduler_error(
     3760                    'source_status_scan_lock',
     3761                    (string) ($scan_lock['error'] ?? $scan_status)
     3762                );
     3763            }
     3764        } else {
     3765            try {
     3766                if (!$this->generic_advisory_lock_session_is_current($scan_lock)) {
     3767                    $this->log_generic_historical_scheduler_error(
     3768                        'source_status_scan_lock',
     3769                        'The MySQL connection changed immediately after acquiring the source-status scan lock; this scan was skipped.'
     3770                    );
     3771                } else {
     3772                    // Source-status reconciliation is durable work even when every
     3773                    // origin is already completed. A persistent keyset cursor ensures
     3774                    // that a busy or failing source cannot pin the first page and
     3775                    // starve later sources when more than 50 sources exist.
     3776                    $cursor_option = 'statpl_generic_status_recovery_cursor';
     3777                    // The cursor is written directly with SQL while proving lock
     3778                    // ownership. Read it directly too: get_option() may return a
     3779                    // stale object-cache value because direct SQL bypasses the
     3780                    // WordPress options cache.
     3781                    $cursor_value = statpl_db_get_var(statpl_db_prepare(
     3782                        "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
     3783                        $cursor_option
     3784                    ));
     3785                    if ($wpdb->last_error !== '') {
     3786                        $this->log_generic_historical_scheduler_error('source_status_recovery_cursor', $wpdb->last_error);
     3787                        $cursor_value = '0';
     3788                    }
     3789                    $source_cursor = absint($cursor_value);
     3790                    $sources = statpl_db_get_results(statpl_db_prepare(
     3791                        "SELECT sources.id FROM {$this->view_sources_table} AS sources
     3792                         WHERE sources.id > %d
     3793                           AND EXISTS (
     3794                               SELECT 1 FROM {$this->view_source_origins_table} AS origins
     3795                               WHERE origins.source_id = sources.id
     3796                           )
     3797                         ORDER BY sources.id ASC
     3798                         LIMIT 50",
     3799                        $source_cursor
     3800                    ), ARRAY_A);
     3801
     3802                    if (!is_array($sources) || !empty($wpdb->last_error)) {
     3803                        $this->log_generic_historical_scheduler_error(
     3804                            'source_status_recovery_scan',
     3805                            !empty($wpdb->last_error) ? $wpdb->last_error : 'Unable to read generic sources for status reconciliation.'
     3806                        );
     3807                    } elseif ($this->generic_advisory_lock_session_is_current($scan_lock)) {
     3808                        // Wrap to the beginning only after reaching the end of the source set.
     3809                        $wrapped_to_start = false;
     3810                        if (!$sources && $source_cursor > 0) {
     3811                            $source_cursor = 0;
     3812                            $wrapped_to_start = true;
     3813                            $sources = statpl_db_get_results(
     3814                                "SELECT sources.id FROM {$this->view_sources_table} AS sources
     3815                                 WHERE EXISTS (
     3816                                     SELECT 1 FROM {$this->view_source_origins_table} AS origins
     3817                                     WHERE origins.source_id = sources.id
     3818                                 )
     3819                                 ORDER BY sources.id ASC
     3820                                 LIMIT 50",
     3821                                ARRAY_A
     3822                            );
     3823                            if (!is_array($sources) || !empty($wpdb->last_error)) {
     3824                                $this->log_generic_historical_scheduler_error(
     3825                                    'source_status_recovery_scan',
     3826                                    !empty($wpdb->last_error) ? $wpdb->last_error : 'Unable to restart generic source status scan.'
     3827                                );
     3828                                $sources = false;
     3829                            }
     3830                        }
     3831
     3832                        if (is_array($sources) && $sources) {
     3833                            $last_scanned_source_id = $source_cursor;
     3834                            $scan_page_completed = true;
     3835                            foreach ($sources as $source) {
     3836                                // If wpdb transparently reconnects, the connection-scoped
     3837                                // scan lock is gone. Stop and leave the cursor unchanged;
     3838                                // already attempted rows are safe to revisit.
     3839                                if (!$this->generic_advisory_lock_session_is_current($scan_lock)) {
     3840                                    $scan_page_completed = false;
     3841                                    $this->log_generic_historical_scheduler_error(
     3842                                        'source_status_scan_lock',
     3843                                        'The MySQL connection changed during source reconciliation; the cursor was not advanced.'
     3844                                    );
     3845                                    break;
     3846                                }
     3847
     3848                                $source_id = absint($source['id'] ?? 0);
     3849                                if (!$source_id) {
     3850                                    continue;
     3851                                }
     3852                                $last_scanned_source_id = max($last_scanned_source_id, $source_id);
     3853                                try {
     3854                                    $reconciliation = $this->refresh_generic_historical_source_status($source_id);
     3855                                    if (!in_array($reconciliation['status'] ?? '', ['updated', 'status_lock_busy', 'source_missing'], true)) {
     3856                                        $this->log_generic_historical_scheduler_error(
     3857                                            'source_status_recovery',
     3858                                            'Source ' . $source_id . ' status reconciliation did not verify: ' . sanitize_key((string) ($reconciliation['status'] ?? 'unknown'))
     3859                                        );
     3860                                    }
     3861                                } catch (Throwable $exception) {
     3862                                    // One corrupt or temporarily failing source must not prevent
     3863                                    // the cursor from reaching the remaining sources.
     3864                                    $this->log_generic_historical_scheduler_error(
     3865                                        'source_status_recovery',
     3866                                        'Source ' . $source_id . ' reconciliation threw: ' . $exception->getMessage()
     3867                                    );
     3868                                }
     3869                            }
     3870
     3871                            // Advance only after the complete bounded page was attempted
     3872                            // and the original scan-lock session is still alive. A source
     3873                            // failure or lock contention is attempted work, not a reason
     3874                            // to starve later IDs; a lost scan session is different.
     3875                            if ($scan_page_completed && $this->generic_advisory_lock_session_is_current($scan_lock)) {
     3876                                // Persist the cursor only in a SQL statement that proves
     3877                                // this exact connection still owns the scan lock. A PHP-
     3878                                // level check followed by update_option() is insufficient:
     3879                                // wpdb may reconnect between those calls and a stale scan
     3880                                // could overwrite the cursor of a newer lock owner.
     3881                                $cursor_persisted = statpl_db_query(statpl_db_prepare(
     3882                                    "INSERT INTO {$wpdb->options} (option_name, option_value, autoload)
     3883                                     SELECT %s, %s, 'no'
     3884                                     WHERE IS_USED_LOCK(%s) = CONNECTION_ID()
     3885                                     ON DUPLICATE KEY UPDATE option_value = IF(
     3886                                         IS_USED_LOCK(%s) = CONNECTION_ID(),
     3887                                         VALUES(option_value),
     3888                                         option_value
     3889                                     )",
     3890                                    $cursor_option,
     3891                                    (string) $last_scanned_source_id,
     3892                                    $scan_lock_name,
     3893                                    $scan_lock_name
     3894                                ));
     3895                                if (!in_array($cursor_persisted, [0, 1, 2], true)) {
     3896                                    $this->log_generic_historical_scheduler_error(
     3897                                        'source_status_recovery_cursor',
     3898                                        !empty($wpdb->last_error) ? $wpdb->last_error : 'Could not persist the source-status recovery cursor while owning the scan lock.'
     3899                                    );
     3900                                }
     3901                                if (!$this->generic_advisory_lock_session_is_current($scan_lock)) {
     3902                                    $this->log_generic_historical_scheduler_error(
     3903                                        'source_status_recovery_cursor',
     3904                                        'The MySQL connection changed while persisting the cursor; the next scan will safely re-evaluate the cursor.'
     3905                                    );
     3906                                } else {
     3907                                    $persisted_cursor_value = statpl_db_get_var(statpl_db_prepare(
     3908                                        "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
     3909                                        $cursor_option
     3910                                    ));
     3911                                    $persisted_cursor = absint($persisted_cursor_value);
     3912                                    if (!empty($wpdb->last_error) || $persisted_cursor !== $last_scanned_source_id) {
     3913                                        $this->log_generic_historical_scheduler_error('source_status_recovery_cursor', 'Could not verify the source-status recovery cursor.');
     3914                                    }
     3915                                }
     3916                            }
     3917                        } elseif (is_array($sources) && !$sources && $wrapped_to_start) {
     3918                            if ($this->generic_advisory_lock_session_is_current($scan_lock)) {
     3919                                // Reset only while the current session still owns the
     3920                                // scan lock; the predicate is checked atomically by SQL.
     3921                                $reset_result = statpl_db_query(statpl_db_prepare(
     3922                                    "INSERT INTO {$wpdb->options} (option_name, option_value, autoload)
     3923                                     SELECT %s, '0', 'no'
     3924                                     WHERE IS_USED_LOCK(%s) = CONNECTION_ID()
     3925                                     ON DUPLICATE KEY UPDATE option_value = IF(
     3926                                         IS_USED_LOCK(%s) = CONNECTION_ID(), '0', option_value
     3927                                     )",
     3928                                    $cursor_option,
     3929                                    $scan_lock_name,
     3930                                    $scan_lock_name
     3931                                ));
     3932                                if ($reset_result === false || !empty($wpdb->last_error)) {
     3933                                    $this->log_generic_historical_scheduler_error(
     3934                                        'source_status_recovery_cursor',
     3935                                        !empty($wpdb->last_error) ? $wpdb->last_error : 'Could not reset the source-status recovery cursor.'
     3936                                    );
     3937                                } elseif ($this->generic_advisory_lock_session_is_current($scan_lock)) {
     3938                                    $reset_cursor = statpl_db_get_var(statpl_db_prepare(
     3939                                        "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
     3940                                        $cursor_option
     3941                                    ));
     3942                                    if (!empty($wpdb->last_error) || absint($reset_cursor) !== 0) {
     3943                                        $this->log_generic_historical_scheduler_error(
     3944                                            'source_status_recovery_cursor',
     3945                                            'Could not verify the reset source-status recovery cursor.'
     3946                                        );
     3947                                    }
     3948                                }
     3949                            }
     3950                        }
     3951                    } else {
     3952                        $this->log_generic_historical_scheduler_error(
     3953                            'source_status_scan_lock',
     3954                            'The MySQL connection changed while reading the source-status page; this page was discarded.'
     3955                        );
     3956                    }
     3957                }
     3958            } catch (Throwable $exception) {
     3959                $this->log_generic_historical_scheduler_error('source_status_recovery_scan', $exception->getMessage());
     3960            } finally {
     3961                $this->release_generic_advisory_lock($scan_lock, 'source_status_recovery_scan');
     3962            }
     3963        }
     3964
     3965        return $requeued;
     3966    }
     3967
     3968    /** Write diagnostic messages only when WordPress debugging is enabled. */
     3969    private function debug_log($message) {
     3970        if (defined('WP_DEBUG') && WP_DEBUG) {
     3971            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- This diagnostic logger writes only when the site owner explicitly enables WP_DEBUG.
     3972            error_log((string) $message);
     3973        }
     3974    }
     3975
     3976    /** Log scheduler failures without allowing diagnostics to interrupt tracking. */
     3977    private function log_generic_historical_scheduler_error($hook, $message) {
     3978        $this->debug_log('[Statora Plus] Historical scheduler failure for ' . sanitize_key((string) $hook) . ': ' . sanitize_text_field((string) $message));
     3979    }
     3980
     3981    /** Schedule retries for deferred records independently from the main import cursor. */
     3982    private function schedule_generic_historical_failure_retry($delay, $source_id, $origin_key) {
     3983        $args = [absint($source_id), sanitize_key((string) $origin_key)];
     3984        $hook = 'statpl_generic_view_retry_failed_records';
     3985        $requested_timestamp = time() + max(1, (int) $delay);
     3986
     3987        if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) {
     3988            $existing_timestamp = false;
     3989            if (function_exists('as_next_scheduled_action')) {
     3990                try {
     3991                    $existing_timestamp = as_next_scheduled_action($hook, $args, 'statpl-plus');
     3992                } catch (Throwable $exception) {
     3993                    $this->log_generic_historical_scheduler_error($hook . '_lookup', $exception->getMessage());
     3994                }
     3995            }
     3996
     3997            // Keep an earlier/equal action, but replace a later one. Otherwise a
     3998            // newly-created short-backoff failure could wait behind an older,
     3999            // long-backoff retry for the same source-origin.
     4000            if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) {
     4001                return true;
     4002            }
     4003            if ($existing_timestamp && function_exists('as_unschedule_all_actions')) {
     4004                try {
     4005                    as_unschedule_all_actions($hook, $args, 'statpl-plus');
     4006                } catch (Throwable $exception) {
     4007                    // The existing action may still be queued. Retain it rather
     4008                    // than create a duplicate whose execution order is unknown.
     4009                    $this->log_generic_historical_scheduler_error($hook . '_unschedule', $exception->getMessage());
     4010                    return true;
     4011                }
     4012            } elseif (function_exists('as_has_scheduled_action')) {
     4013                try {
     4014                    if (as_has_scheduled_action($hook, $args, 'statpl-plus')) {
     4015                        // Without reliable timestamp/removal APIs, retain the
     4016                        // existing action rather than duplicate the worker.
     4017                        return true;
     4018                    }
     4019                } catch (Throwable $exception) {
     4020                    $this->log_generic_historical_scheduler_error($hook . '_presence_check', $exception->getMessage());
     4021                }
     4022            }
     4023            try {
     4024                $action_id = as_schedule_single_action($requested_timestamp, $hook, $args, 'statpl-plus');
     4025                if ($action_id) {
     4026                    return true;
     4027                }
     4028            } catch (Throwable $exception) {
     4029                $this->log_generic_historical_scheduler_error($hook, $exception->getMessage());
     4030            }
     4031            // Fall through to WP-Cron if Action Scheduler did not accept the action.
     4032        }
     4033
     4034        try {
     4035            $existing_timestamp = wp_next_scheduled($hook, $args);
     4036            if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) {
     4037                return true;
     4038            }
     4039            if ($existing_timestamp) {
     4040                wp_unschedule_event((int) $existing_timestamp, $hook, $args);
     4041            }
     4042            $scheduled = wp_schedule_single_event($requested_timestamp, $hook, $args);
     4043            if (!$scheduled || is_wp_error($scheduled)) {
     4044                $this->log_generic_historical_scheduler_error($hook, is_wp_error($scheduled) ? $scheduled->get_error_message() : 'WP-Cron rejected the event.');
     4045                return false;
     4046            }
     4047            return true;
     4048        } catch (Throwable $exception) {
     4049            $this->log_generic_historical_scheduler_error($hook, $exception->getMessage());
     4050            return false;
     4051        }
     4052    }
     4053
     4054    /** Acquire a connection-scoped advisory lock and capture its owning session. */
     4055    private function acquire_generic_advisory_lock($lock_name) {
     4056        global $wpdb;
     4057        try {
     4058            $row = statpl_db_get_row(statpl_db_prepare(
     4059                'SELECT CONNECTION_ID() AS connection_id, GET_LOCK(%s, 0) AS lock_acquired',
     4060                (string) $lock_name
     4061            ), ARRAY_A);
     4062        } catch (Throwable $exception) {
     4063            return ['acquired' => false, 'status' => 'lock_error', 'error' => $exception->getMessage()];
     4064        }
     4065        if (!is_array($row) || !empty($wpdb->last_error) || (string) ($row['lock_acquired'] ?? '') !== '1' || empty($row['connection_id'])) {
     4066            return ['acquired' => false, 'status' => 'lock_busy_or_unavailable', 'error' => (string) $wpdb->last_error];
     4067        }
     4068        return ['acquired' => true, 'connection_id' => (string) $row['connection_id'], 'lock_name' => (string) $lock_name];
     4069    }
     4070
     4071    /** Roll back an open import transaction only on its original DB session. */
     4072    private function rollback_generic_import_transaction(&$transaction_open, $origin_lock) {
     4073        global $wpdb;
     4074        if (!$transaction_open) {
     4075            return true;
     4076        }
     4077        // Never issue transaction control on a transparently reconnected session.
     4078        // The caller may retry once from finally if this attempt fails.
     4079        for ($attempt = 0; $attempt < 1; $attempt++) {
     4080            if (!$this->generic_db_session_matches_lock($origin_lock)) {
     4081                return false;
     4082            }
     4083            $result = statpl_db_query('ROLLBACK');
     4084            if ($result !== false) {
     4085                $transaction_open = false;
     4086                return true;
     4087            }
     4088        }
     4089        return false;
     4090    }
     4091
     4092    /** Confirm the current wpdb connection is the session that acquired a lock, regardless of lock ownership. */
     4093    private function generic_db_session_matches_lock($lock) {
     4094        global $wpdb;
     4095        if (!is_array($lock) || empty($lock['acquired']) || empty($lock['connection_id'])) {
     4096            return false;
     4097        }
     4098        try {
     4099            $connection_id = statpl_db_get_var('SELECT CONNECTION_ID()');
     4100        } catch (Throwable $exception) {
     4101            return false;
     4102        }
     4103        return $connection_id !== null
     4104            && empty($wpdb->last_error)
     4105            && (string) $connection_id === (string) $lock['connection_id'];
     4106    }
     4107
     4108    /** Confirm the current wpdb session still owns the lock-acquiring connection. */
     4109    private function generic_advisory_lock_session_is_current($lock) {
     4110        global $wpdb;
     4111        if (!is_array($lock) || empty($lock['acquired']) || empty($lock['connection_id'])) {
     4112            return false;
     4113        }
     4114        try {
     4115            // Session identity alone is insufficient: code running on the same
     4116            // connection could have released or replaced the named lock. Verify
     4117            // both the connection and the actual lock owner in one SQL read.
     4118            $row = statpl_db_get_row(statpl_db_prepare(
     4119                'SELECT CONNECTION_ID() AS connection_id, IS_USED_LOCK(%s) AS lock_owner',
     4120                (string) $lock['lock_name']
     4121            ), ARRAY_A);
     4122        } catch (Throwable $exception) {
     4123            return false;
     4124        }
     4125        return is_array($row)
     4126            && empty($wpdb->last_error)
     4127            && (string) ($row['connection_id'] ?? '') === (string) $lock['connection_id']
     4128            && (string) ($row['lock_owner'] ?? '') === (string) $lock['connection_id'];
     4129    }
     4130
     4131    /** Release only on the original session; a lost session releases its own locks server-side. */
     4132    private function release_generic_advisory_lock($lock, $context = '') {
     4133        global $wpdb;
     4134        if (!$this->generic_advisory_lock_session_is_current($lock)) {
     4135            if (defined('WP_DEBUG') && WP_DEBUG) {
     4136                $this->debug_log('Statora Plus: advisory lock session changed before release' . ($context !== '' ? ' (' . sanitize_key($context) . ')' : '') . '.');
     4137            }
     4138            return false;
     4139        }
     4140        try {
     4141            $released = statpl_db_get_var(statpl_db_prepare('SELECT RELEASE_LOCK(%s)', (string) $lock['lock_name']));
     4142            if ((string) $released === '1' && empty($wpdb->last_error)) {
     4143                return true;
     4144            }
     4145        } catch (Throwable $exception) {
     4146            if (defined('WP_DEBUG') && WP_DEBUG) {
     4147                $this->debug_log('Statora Plus: advisory lock release failed' . ($context !== '' ? ' (' . sanitize_key($context) . ')' : '') . ': ' . $exception->getMessage());
     4148            }
     4149            return false;
     4150        }
     4151        if (defined('WP_DEBUG') && WP_DEBUG) {
     4152            $this->debug_log('Statora Plus: advisory lock release was not confirmed' . ($context !== '' ? ' (' . sanitize_key($context) . ')' : '') . '.');
     4153        }
     4154        return false;
     4155    }
     4156
     4157    /** Replay due failed records from their saved immutable batch checkpoint. */
     4158    public function retry_generic_historical_view_failures($source_id = 0, $origin_key = '') {
     4159        global $wpdb;
     4160        $source_id = absint($source_id);
     4161        $origin_key = sanitize_key((string) $origin_key);
     4162        if (!$source_id || $origin_key === '') {
     4163            return ['status' => 'invalid_input'];
     4164        }
     4165
     4166        $now = current_time('mysql');
     4167        $failure = statpl_db_get_row(statpl_db_prepare(
     4168            "SELECT retry_checkpoint, next_retry_at FROM {$this->view_source_failures_table}
     4169             WHERE source_id = %d AND origin_key = %s AND status = 'retry' AND next_retry_at <= %s
     4170               AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> ''
     4171             ORDER BY next_retry_at ASC, id ASC LIMIT 1",
     4172            $source_id,
     4173            $origin_key,
     4174            $now
     4175        ), ARRAY_A);
     4176        if (!empty($wpdb->last_error)) {
     4177            $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key);
     4178            return ['status' => 'failure_lookup_error'];
     4179        }
     4180        if (!$failure) {
     4181            $next = statpl_db_get_var(statpl_db_prepare(
     4182                "SELECT MIN(next_retry_at) FROM {$this->view_source_failures_table}
     4183                 WHERE source_id = %d AND origin_key = %s AND status = 'retry'
     4184                   AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> ''",
     4185                $source_id,
     4186                $origin_key
     4187            ));
     4188            if (!empty($wpdb->last_error)) {
     4189                // A failed deadline lookup is not evidence that the retry queue is
     4190                // empty. Preserve the worker trigger and avoid status aggregation
     4191                // from an incomplete view of the durable failure ledger.
     4192                $this->log_generic_historical_scheduler_error('retry_ledger_next_deadline', $wpdb->last_error);
     4193                $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key);
     4194                return ['status' => 'failure_ledger_deadline_failed'];
     4195            }
     4196            if ($next) {
     4197                $next_timestamp = strtotime((string) $next);
     4198                $delay = ($next_timestamp !== false) ? max(1, $next_timestamp - current_time('timestamp')) : 300;
     4199                $this->schedule_generic_historical_failure_retry(min(86400, $delay), $source_id, $origin_key);
     4200            }
     4201            $this->refresh_generic_historical_source_status($source_id);
     4202            return ['status' => 'no_due_failures'];
     4203        }
     4204
     4205        $lock_name = 'statpl_generic_' . substr(hash('sha256', $wpdb->prefix . ':' . $source_id . ':' . $origin_key), 0, 40);
     4206        $origin_lock = $this->acquire_generic_advisory_lock($lock_name);
     4207        if (empty($origin_lock['acquired'])) {
     4208            $this->schedule_generic_historical_failure_retry(30, $source_id, $origin_key);
     4209            return ['status' => 'worker_already_running'];
     4210        }
     4211
     4212        try {
     4213            // The initial lookup above only determines whether this worker appears
     4214            // eligible. Re-read the durable row after acquiring the same origin lock
     4215            // used by the main importer: another worker may have resolved or changed
     4216            // this retry while we were waiting. Never replay a stale checkpoint.
     4217            if (!$this->generic_advisory_lock_session_is_current($origin_lock)) {
     4218                $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key);
     4219                return ['status' => 'origin_lock_session_lost'];
     4220            }
     4221            $locked_failure = statpl_db_get_row(statpl_db_prepare(
     4222                "SELECT retry_checkpoint, next_retry_at FROM {$this->view_source_failures_table}
     4223                 WHERE source_id = %d AND origin_key = %s AND status = 'retry'
     4224                   AND next_retry_at <= %s AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> ''
     4225                 ORDER BY next_retry_at ASC, id ASC LIMIT 1",
     4226                $source_id,
     4227                $origin_key,
     4228                current_time('mysql')
     4229            ), ARRAY_A);
     4230            if (!empty($wpdb->last_error)) {
     4231                $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key);
     4232                return ['status' => 'failure_lookup_error'];
     4233            }
     4234            if (!$locked_failure) {
     4235                return ['status' => 'retry_no_longer_due'];
     4236            }
     4237
     4238            $retry_checkpoint = json_decode($locked_failure['retry_checkpoint'], true);
     4239            if (!is_array($retry_checkpoint) || !$retry_checkpoint) {
     4240                // Corrupt replay metadata is not silently discarded or counted as a view.
     4241                $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key);
     4242                return ['status' => 'invalid_retry_checkpoint'];
     4243            }
     4244
     4245            if (!$this->generic_advisory_lock_session_is_current($origin_lock)) {
     4246                $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key);
     4247                return ['status' => 'origin_lock_session_lost'];
     4248            }
     4249            $result = $this->process_generic_historical_view_import_batch($source_id, $origin_key, $retry_checkpoint, $origin_lock);
     4250        } finally {
     4251            $this->release_generic_advisory_lock($origin_lock, 'retry_origin');
     4252        }
     4253
     4254        // The processing lock above protects replay itself. Reacquire the same
     4255        // origin lock before reconciling ledger counts and origin status: the main
     4256        // importer may have started immediately after the replay lock was released.
     4257        // Re-reading under the lock prevents this worker from applying stale status
     4258        // decisions over a newer main-import checkpoint/state transition.
     4259        $post_origin_lock = $this->acquire_generic_advisory_lock($lock_name);
     4260        if (empty($post_origin_lock['acquired'])) {
     4261            $this->schedule_generic_historical_failure_retry(30, $source_id, $origin_key);
     4262            return ['status' => 'post_processing_lock_busy'];
     4263        }
     4264
     4265        try {
     4266        if (!$this->generic_advisory_lock_session_is_current($post_origin_lock)) {
     4267            $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key);
     4268            return ['status' => 'post_processing_lock_session_lost'];
     4269        }
     4270        $remaining_value = statpl_db_get_var(statpl_db_prepare(
     4271            "SELECT COUNT(*) FROM {$this->view_source_failures_table}
     4272             WHERE source_id = %d AND origin_key = %s AND status = 'retry'
     4273               AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> ''",
     4274            $source_id,
     4275            $origin_key
     4276        ));
     4277        if (!empty($wpdb->last_error) || $remaining_value === null) {
     4278            // A failed count must never be interpreted as an empty ledger. Keep
     4279            // the worker alive and let the next pass re-read durable retry rows.
     4280            $this->log_generic_historical_scheduler_error('retry_ledger_count', $wpdb->last_error ?: 'Retry ledger count returned NULL.');
     4281            $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key);
     4282            return ['status' => 'failure_ledger_count_failed'];
     4283        }
     4284        $remaining = (int) $remaining_value;
     4285        if ($remaining > 0) {
     4286            $next = statpl_db_get_var(statpl_db_prepare(
     4287                "SELECT MIN(next_retry_at) FROM {$this->view_source_failures_table}
     4288                 WHERE source_id = %d AND origin_key = %s AND status = 'retry'
     4289                   AND retry_checkpoint IS NOT NULL AND retry_checkpoint <> ''",
     4290                $source_id,
     4291                $origin_key
     4292            ));
     4293            if (!empty($wpdb->last_error)) {
     4294                $this->log_generic_historical_scheduler_error('retry_ledger_deadline', $wpdb->last_error);
     4295                $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key);
     4296                return ['status' => 'failure_ledger_deadline_failed', 'remaining' => $remaining];
     4297            }
     4298            $next_timestamp = $next ? strtotime((string) $next) : false;
     4299            $delay = ($next_timestamp !== false) ? max(1, $next_timestamp - current_time('timestamp')) : 300;
     4300            $this->schedule_generic_historical_failure_retry(min(86400, $delay), $source_id, $origin_key);
     4301        } else {
     4302            $blocked_value = statpl_db_get_var(statpl_db_prepare(
     4303                "SELECT COUNT(*) FROM {$this->view_source_failures_table}
     4304                 WHERE source_id = %d AND origin_key = %s AND status = 'blocked'",
     4305                $source_id,
     4306                $origin_key
     4307            ));
     4308            if (!empty($wpdb->last_error) || $blocked_value === null) {
     4309                $this->log_generic_historical_scheduler_error('blocked_ledger_count', $wpdb->last_error ?: 'Blocked ledger count returned NULL.');
     4310                $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key);
     4311                return ['status' => 'failure_ledger_count_failed'];
     4312            }
     4313            $blocked = (int) $blocked_value;
     4314            $current_state = $this->get_view_source_origin_state($source_id, $origin_key);
     4315            $origin_status = $current_state['origin_status'] ?? '';
     4316            if (in_array($origin_status, ['pending', 'snapshot_ready', 'importing', 'retry_wait'], true)) {
     4317                // The retry worker cannot infer whether the main scan has reached
     4318                // the immutable snapshot boundary. This remains true when some
     4319                // records are already blocked: the main cursor may still have
     4320                // later batches to import. Resume the main scan and let its own
     4321                // has_more result, together with the durable ledger counts, decide
     4322                // whether the final state is completed or needs_attention.
     4323                $updates = [
     4324                    'status' => 'importing',
     4325                    'completed_at' => null,
     4326                ];
     4327                if ($blocked === 0) {
     4328                    $updates['last_error'] = null;
     4329                }
     4330                $origin_state_result = $this->update_view_source_origin_state($source_id, $origin_key, $updates, $post_origin_lock);
     4331                if (!in_array($origin_state_result['status'] ?? '', ['updated'], true)) {
     4332                    // Do not treat a failed/ambiguous state transition as a
     4333                    // successful retry finalization. Keep recovery scheduled.
     4334                    $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key);
     4335                    return [
     4336                        'status' => 'origin_state_recovery_failed',
     4337                        'origin_state' => $origin_state_result,
     4338                        'result' => $result,
     4339                        'remaining' => $remaining,
     4340                    ];
     4341                }
     4342                $this->schedule_generic_historical_view_import_batch(5, $source_id, $origin_key);
     4343            } elseif ($blocked > 0 && $origin_status !== 'needs_attention') {
     4344                // Only a non-progressing, nonterminal origin may be finalized here.
     4345                // Never downgrade an already-finalized attention state.
     4346                $origin_state_result = $this->update_view_source_origin_state($source_id, $origin_key, [
     4347                    'status' => 'needs_attention',
     4348                    'last_error' => 'Some historical records could not be recovered after three total attempts.',
     4349                ], $post_origin_lock);
     4350                if (!in_array($origin_state_result['status'] ?? '', ['updated'], true)) {
     4351                    $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key);
     4352                    return [
     4353                        'status' => 'origin_state_recovery_failed',
     4354                        'origin_state' => $origin_state_result,
     4355                        'result' => $result,
     4356                        'remaining' => $remaining,
     4357                    ];
     4358                }
     4359            }
     4360        }
     4361
     4362        // Ledger reconciliation may itself encounter a reconnect after the
     4363        // post-processing lock was acquired. Never aggregate source status unless
     4364        // this request still owns that exact MySQL lock session.
     4365        if ($this->generic_advisory_lock_session_is_current($post_origin_lock)) {
     4366            $this->refresh_generic_historical_source_status($source_id);
     4367        } else {
     4368            $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key);
     4369            return ['status' => 'post_processing_session_lost', 'result' => $result, 'remaining' => $remaining];
     4370        }
     4371        return ['status' => 'retry_processed', 'result' => $result, 'remaining' => $remaining];
     4372        } finally {
     4373            $this->release_generic_advisory_lock($post_origin_lock, 'retry_post_processing');
     4374        }
     4375    }
     4376
     4377    /** Return a discovered adapter instance for later snapshot/import workers. */
     4378    private function get_historical_view_adapter($adapter_key) {
     4379        $adapter_key = sanitize_key((string) $adapter_key);
     4380        return isset($this->historical_view_adapters[$adapter_key])
     4381            ? $this->historical_view_adapters[$adapter_key]
     4382            : null;
     4383    }
     4384
     4385    /** Schedule the generic historical importer without overlapping scheduled runs. */
     4386    private function schedule_generic_historical_view_import_batch($delay = 10, $source_id = 0, $origin_key = '') {
     4387        $args = [(int) $source_id, (string) $origin_key];
     4388        $hook = 'statpl_generic_view_import_batch';
     4389        $requested_timestamp = time() + max(1, (int) $delay);
     4390
     4391        if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) {
     4392            $existing_timestamp = false;
     4393            if (function_exists('as_next_scheduled_action')) {
     4394                try {
     4395                    $existing_timestamp = as_next_scheduled_action($hook, $args, 'statpl-plus');
     4396                } catch (Throwable $exception) {
     4397                    $this->log_generic_historical_scheduler_error($hook . '_lookup', $exception->getMessage());
     4398                }
     4399            }
     4400            if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) {
     4401                return true;
     4402            }
     4403            if ($existing_timestamp && function_exists('as_unschedule_all_actions')) {
     4404                try {
     4405                    as_unschedule_all_actions($hook, $args, 'statpl-plus');
     4406                } catch (Throwable $exception) {
     4407                    $this->log_generic_historical_scheduler_error($hook . '_unschedule', $exception->getMessage());
     4408                    return true;
     4409                }
     4410            } elseif ($existing_timestamp) {
     4411                return true;
     4412            } elseif (function_exists('as_has_scheduled_action')) {
     4413                try {
     4414                    if (as_has_scheduled_action($hook, $args, 'statpl-plus')) {
     4415                        // Without reliable timing/removal APIs, retain the existing action.
     4416                        return true;
     4417                    }
     4418                } catch (Throwable $exception) {
     4419                    $this->log_generic_historical_scheduler_error($hook . '_presence_check', $exception->getMessage());
     4420                }
     4421            }
     4422            try {
     4423                $action_id = as_schedule_single_action($requested_timestamp, $hook, $args, 'statpl-plus');
     4424                if ($action_id) {
     4425                    return true;
     4426                }
     4427            } catch (Throwable $exception) {
     4428                $this->log_generic_historical_scheduler_error($hook, $exception->getMessage());
     4429            }
     4430            // Fall through to WP-Cron if Action Scheduler did not accept the action.
     4431        }
     4432
     4433        try {
     4434            $existing_timestamp = wp_next_scheduled($hook, $args);
     4435            if ($existing_timestamp && (int) $existing_timestamp <= $requested_timestamp) {
     4436                return true;
     4437            }
     4438            if ($existing_timestamp) {
     4439                wp_unschedule_event((int) $existing_timestamp, $hook, $args);
     4440            }
     4441            $scheduled = wp_schedule_single_event($requested_timestamp, $hook, $args);
     4442            if (!$scheduled || is_wp_error($scheduled)) {
     4443                $this->log_generic_historical_scheduler_error($hook, is_wp_error($scheduled) ? $scheduled->get_error_message() : 'WP-Cron rejected the event.');
     4444                return false;
     4445            }
     4446            return true;
     4447        } catch (Throwable $exception) {
     4448            $this->log_generic_historical_scheduler_error($hook, $exception->getMessage());
     4449            return false;
     4450        }
     4451    }
     4452
     4453    /** Persist a generic-source failure with bounded exponential retry metadata. */
     4454    private function record_generic_historical_view_failure($source_id, $origin_key, $record_key, $product_id, $error, $retry_checkpoint = null, $max_attempts = 8, $lock_context = null) {
     4455        global $wpdb;
     4456
     4457        $source_id = absint($source_id);
     4458        if (!$this->generic_advisory_lock_session_is_current($lock_context)) {
     4459            return ['attempts' => 0, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'lock_session_lost'];
     4460        }
     4461        $record_key = substr((string) $record_key, 0, 191);
     4462        if ($record_key === '') {
     4463            $record_key = 'batch:' . substr(hash('sha256', (string) $origin_key), 0, 32);
     4464        }
     4465        $record_hash = hash('sha256', (string) $origin_key . "\0" . $record_key);
     4466        $existing = statpl_db_get_row(statpl_db_prepare(
     4467            "SELECT id, attempts, status, next_retry_at, retry_checkpoint, last_error FROM {$this->view_source_failures_table} WHERE source_id = %d AND source_record_hash = %s LIMIT 1",
     4468            $source_id,
     4469            $record_hash
     4470        ), ARRAY_A);
     4471        if (!empty($wpdb->last_error)) {
     4472            $this->log_generic_historical_scheduler_error('failure_ledger_lookup', $wpdb->last_error);
     4473            return ['attempts' => 0, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'ledger_lookup_failed'];
     4474        }
     4475        if (!$this->generic_advisory_lock_session_is_current($lock_context)) {
     4476            return ['attempts' => 0, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'lock_session_lost'];
     4477        }
     4478        $now = current_time('mysql');
     4479        if ($existing && isset($existing['status']) && $existing['status'] === 'blocked') {
     4480            // The ledger row may have been committed even if the corresponding
     4481            // origin-state update failed on the previous pass. Repair that
     4482            // derived state on every subsequent locked observation without
     4483            // incrementing attempts or rewriting the durable failure record.
     4484            $existing_origin_status = empty($existing['retry_checkpoint']) ? 'needs_attention' : 'retry_wait';
     4485            $origin_state_saved = $this->update_view_source_origin_state($source_id, $origin_key, [
     4486                'last_error' => substr((string) ($existing['last_error'] ?? ''), 0, 2000),
     4487                'status' => $existing_origin_status,
     4488            ], $lock_context);
     4489            if (empty($origin_state_saved['status']) || $origin_state_saved['status'] !== 'updated') {
     4490                $this->log_generic_historical_scheduler_error('failure_origin_state_recovery', $origin_state_saved['error'] ?? ($origin_state_saved['status'] ?? 'Unable to recover origin state from the existing blocked failure record.'));
     4491                $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     4492                return ['attempts' => (int) $existing['attempts'], 'blocked' => true, 'persisted' => true, 'retry_delay' => 0, 'status' => 'origin_state_recovery_failed'];
     4493            }
     4494            return ['attempts' => (int) $existing['attempts'], 'blocked' => true, 'persisted' => true, 'retry_delay' => 0, 'status' => 'already_blocked'];
     4495        }
     4496        if ($existing && !empty($existing['next_retry_at']) && $existing['next_retry_at'] > $now) {
     4497            $retry_at = strtotime((string) $existing['next_retry_at']);
     4498            $now_at = strtotime((string) $now);
     4499            $remaining = ($retry_at !== false && $now_at !== false) ? max(1, $retry_at - $now_at) : 60;
     4500            $origin_state_saved = $this->update_view_source_origin_state($source_id, $origin_key, [
     4501                'last_error' => substr((string) ($existing['last_error'] ?? ''), 0, 2000),
     4502                'status' => 'retry_wait',
     4503            ], $lock_context);
     4504            if (empty($origin_state_saved['status']) || $origin_state_saved['status'] !== 'updated') {
     4505                $this->log_generic_historical_scheduler_error('failure_origin_state_recovery', $origin_state_saved['error'] ?? ($origin_state_saved['status'] ?? 'Unable to recover origin state from the existing retry failure record.'));
     4506                $this->schedule_generic_historical_failure_retry(min(86400, $remaining), $source_id, $origin_key);
     4507                return ['attempts' => (int) $existing['attempts'], 'blocked' => true, 'persisted' => true, 'retry_delay' => 0, 'status' => 'origin_state_recovery_failed'];
     4508            }
     4509            return ['attempts' => (int) $existing['attempts'], 'blocked' => false, 'persisted' => true, 'retry_delay' => min(86400, $remaining), 'status' => 'retry_already_scheduled'];
     4510        }
     4511
     4512        $attempts = $existing ? ((int) $existing['attempts'] + 1) : 1;
     4513        $max_attempts = max(1, absint($max_attempts));
     4514        $blocked = $attempts >= $max_attempts;
     4515        $delay = min(86400, 60 * (2 ** min(10, max(0, $attempts - 1))));
     4516        $next_retry = $blocked ? null : current_datetime()->modify('+' . $delay . ' seconds')->format('Y-m-d H:i:s');
     4517        $data = [
     4518            'source_id' => $source_id,
     4519            'origin_key' => sanitize_key((string) $origin_key),
     4520            'source_record_key' => $record_key,
     4521            'source_record_hash' => $record_hash,
     4522            'product_id' => absint($product_id),
     4523            'attempts' => $attempts,
     4524            'last_error' => substr(sanitize_textarea_field((string) $error), 0, 60000),
     4525            'status' => $blocked ? 'blocked' : 'retry',
     4526            'next_retry_at' => $next_retry,
     4527            'retry_checkpoint' => is_array($retry_checkpoint) ? wp_json_encode($retry_checkpoint) : null,
     4528            'updated_at' => $now,
     4529        ];
     4530        $allowed_columns = [
     4531            'source_id', 'origin_key', 'source_record_key', 'source_record_hash',
     4532            'product_id', 'attempts', 'last_error', 'status', 'next_retry_at',
     4533            'retry_checkpoint', 'updated_at',
     4534        ];
     4535        $columns = array_keys($data);
     4536        if (array_diff($columns, $allowed_columns) || array_diff($allowed_columns, $columns)) {
     4537            $this->log_generic_historical_scheduler_error('failure_ledger_schema', 'Failure ledger data did not match the fixed column allowlist.');
     4538            return ['attempts' => $attempts, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'ledger_schema_mismatch'];
     4539        }
     4540        $placeholders = [];
     4541        $values = [];
     4542        foreach ($allowed_columns as $column) {
     4543            $value = $data[$column];
     4544            $placeholders[] = is_int($value) ? '%d' : '%s';
     4545            $values[] = $value;
     4546        }
     4547        $lock_name = (string) ($lock_context['lock_name'] ?? '');
     4548        if ($lock_name === '' || !$this->generic_advisory_lock_session_is_current($lock_context)) {
     4549            return ['attempts' => $attempts, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'lock_session_lost'];
     4550        }
     4551
     4552        if ($existing) {
     4553            // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The SET placeholders are generated from the fixed allowlist; update_values is generated from the same allowlist with the same immutable key columns excluded, followed by id and lock_name.
     4554            $set = [];
     4555            foreach ($allowed_columns as $column) {
     4556                if (in_array($column, ['source_id', 'source_record_hash'], true)) {
     4557                    continue;
     4558                }
     4559                $value = $data[$column];
     4560                $set[] = "`{$column}` = " . (is_int($value) ? '%d' : '%s');
     4561            }
     4562            $update_values = [];
     4563            foreach ($allowed_columns as $column) {
     4564                if (!in_array($column, ['source_id', 'source_record_hash'], true)) {
     4565                    $update_values[] = $data[$column];
     4566                }
     4567            }
     4568            // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The SET placeholder list and update_values are built in the same pass over the fixed 11-column allowlist, excluding the same two immutable key columns; the two trailing placeholders are always id and lock_name.
     4569// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     4570            $query = /* phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Placeholder fragments and their values are assembled together from the same fixed allowlists; optional lock predicates and matching values are added together. */ statpl_db_prepare(
     4571                "UPDATE {$this->view_source_failures_table} SET " . implode(', ', /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $set) . " WHERE id = %d AND IS_USED_LOCK(%s) = CONNECTION_ID()",
     4572                array_merge($update_values, [(int) $existing['id'], $lock_name])
     4573            );
     4574        } else {
     4575// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     4576            $query = statpl_db_prepare(
     4577                "INSERT INTO {$this->view_source_failures_table} (" . implode(', ', /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ array_map(function ($column) { return "`{$column}`"; }, $columns)) . ") SELECT " . implode(', ', $placeholders) . " WHERE IS_USED_LOCK(%s) = CONNECTION_ID()",
     4578                array_merge($values, [$lock_name])
     4579            );
     4580        }
     4581        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $query is assigned exclusively from statpl_db_prepare() in both branches; dynamic identifiers and column names come from fixed allowlists, and all data values use placeholders.
     4582// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     4583        $persisted = statpl_db_query($query);
     4584        if ($persisted === false || $wpdb->last_error !== '') {
     4585            return ['attempts' => $attempts, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'ledger_write_failed', 'error' => $wpdb->last_error];
     4586        }
     4587        if ($persisted !== 1) {
     4588            return ['attempts' => $attempts, 'blocked' => true, 'persisted' => false, 'retry_delay' => 0, 'status' => 'lock_session_lost'];
     4589        }
     4590        if (strpos($record_key, 'batch:') !== 0 && $record_key !== 'snapshot_initialization') {
     4591            $this->save_generic_historical_report_record($source_id, $origin_key, $record_hash, $blocked ? 'failed' : 'retry', $attempts, 'record_import_failed');
     4592        }
     4593
     4594        $origin_status = ($blocked && !is_array($retry_checkpoint)) ? 'needs_attention' : 'retry_wait';
     4595        $origin_state_saved = $this->update_view_source_origin_state($source_id, $origin_key, [
     4596            'last_error' => substr((string) $error, 0, 2000),
     4597            'status' => $origin_status,
     4598        ], $lock_context);
     4599        if (empty($origin_state_saved['status']) || !in_array($origin_state_saved['status'], ['updated'], true)) {
     4600            $this->log_generic_historical_scheduler_error('failure_origin_state', $origin_state_saved['error'] ?? ($origin_state_saved['status'] ?? 'Unable to update origin state after recording a failure.'));
     4601            // The failure row is durable, but its derived origin status may be
     4602            // stale. Ensure another locked pass is scheduled to repair the
     4603            // origin state; do not rely on callers, which correctly stop when
     4604            // this operation reports blocked=true.
     4605            if (is_array($retry_checkpoint)) {
     4606                $this->schedule_generic_historical_failure_retry(max(60, $delay), $source_id, $origin_key);
     4607            } else {
     4608                $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     4609            }
     4610            return ['attempts' => $attempts, 'blocked' => true, 'persisted' => true, 'retry_delay' => 0, 'status' => 'origin_state_update_failed'];
     4611        }
     4612
     4613        return ['attempts' => $attempts, 'blocked' => $blocked, 'persisted' => true, 'retry_delay' => $blocked ? 0 : $delay];
     4614    }
     4615
     4616    /** Clear a previously recorded failure after its record is safely handled. */
     4617    private function clear_generic_historical_view_failure($source_id, $origin_key, $record_key, $lock_context = null, $report_outcome = 'imported') {
     4618        global $wpdb;
     4619        if (!$this->generic_advisory_lock_session_is_current($lock_context)) {
     4620            return false;
     4621        }
     4622        $record_key = substr((string) $record_key, 0, 191);
     4623        if ($record_key === '') {
     4624            return false;
     4625        }
     4626        $record_hash = hash('sha256', (string) $origin_key . "\0" . $record_key);
     4627        $existing_failure = statpl_db_get_row(statpl_db_prepare(
     4628            "SELECT attempts FROM {$this->view_source_failures_table} WHERE source_id = %d AND source_record_hash = %s LIMIT 1",
     4629            absint($source_id), $record_hash
     4630        ), ARRAY_A);
     4631        if (!empty($wpdb->last_error)) { return false; }
     4632        if ($report_outcome === 'imported' && strpos($record_key, 'batch:') !== 0 && $record_key !== 'snapshot_initialization') {
     4633            $attempts = is_array($existing_failure) ? (int) ($existing_failure['attempts'] ?? 0) : 0;
     4634            if (!$this->save_generic_historical_report_record($source_id, $origin_key, $record_hash, 'imported', $attempts, '')) { return false; }
     4635        }
     4636        $lock_name = (string) ($lock_context['lock_name'] ?? '');
     4637        if ($lock_name === '' || !$this->generic_advisory_lock_session_is_current($lock_context)) {
     4638            return false;
     4639        }
     4640        $deleted = statpl_db_query(statpl_db_prepare(
     4641            "DELETE FROM {$this->view_source_failures_table} WHERE source_id = %d AND source_record_hash = %s AND IS_USED_LOCK(%s) = CONNECTION_ID()",
     4642            absint($source_id),
     4643            $record_hash,
     4644            $lock_name
     4645        ));
     4646        if ($deleted === false || $wpdb->last_error !== '') {
     4647            $this->log_generic_historical_scheduler_error('failure_ledger_clear', $wpdb->last_error ?: 'Unable to clear a resolved failure record.');
     4648            return false;
     4649        }
     4650        // A zero-row DELETE is ambiguous: the failure row may already be absent,
     4651        // or the SQL ownership predicate may have rejected the operation after a
     4652        // reconnect/lock loss. Only treat the former as successful, and confirm
     4653        // that this worker still owns the original lock session after the query.
     4654        if (!$this->generic_advisory_lock_session_is_current($lock_context)) {
     4655            return false;
     4656        }
     4657        return true;
     4658    }
     4659
     4660    /**
     4661     * Process one generic source-origin batch. Each accepted record is committed
     4662     * atomically with its source event and product total; the checkpoint advances
     4663     * only after the record is safely applied or recognized as an exact duplicate.
     4664     */
     4665    public function run_generic_historical_view_import_batch($source_id = 0, $origin_key = '') {
     4666        global $wpdb;
     4667
     4668        $source_id = absint($source_id);
     4669        $origin_key = sanitize_key((string) $origin_key);
     4670        if (!$source_id || $origin_key === '') {
     4671            return ['status' => 'invalid_input'];
     4672        }
     4673
     4674        // A connection-scoped MySQL advisory lock prevents two cron/Action
     4675        // Scheduler workers from advancing the same source-origin checkpoint.
     4676        // The lock is released automatically if PHP or the DB connection dies.
     4677        $lock_name = 'statpl_generic_' . substr(hash('sha256', $wpdb->prefix . ':' . $source_id . ':' . $origin_key), 0, 40);
     4678        $origin_lock = $this->acquire_generic_advisory_lock($lock_name);
     4679        if (empty($origin_lock['acquired'])) {
     4680            $this->schedule_generic_historical_view_import_batch(30, $source_id, $origin_key);
     4681            return ['status' => 'worker_already_running'];
     4682        }
     4683
     4684        try {
     4685            if (!$this->generic_advisory_lock_session_is_current($origin_lock)) {
     4686                $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     4687                return ['status' => 'origin_lock_session_lost'];
     4688            }
     4689            $result = $this->process_generic_historical_view_import_batch($source_id, $origin_key, null, $origin_lock);
     4690            // The processing method can return after detecting a lost MySQL session.
     4691            // Do not start a new source-status write on a transparently reconnected
     4692            // session: it would run outside the origin lock that serialized this worker.
     4693            if ($this->generic_advisory_lock_session_is_current($origin_lock)) {
     4694                $this->refresh_generic_historical_source_status($source_id);
     4695            } else {
     4696                $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     4697            }
     4698            return $result;
     4699        } finally {
     4700            $this->release_generic_advisory_lock($origin_lock, 'main_importer');
     4701        }
     4702    }
     4703
     4704    /** Validate a normalized source datetime without allowing silent MySQL coercion. */
     4705    private function is_valid_generic_historical_datetime($value) {
     4706        if ($value === null || $value === '') {
     4707            return true;
     4708        }
     4709        if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $value)) {
     4710            return false;
     4711        }
     4712        $date = DateTime::createFromFormat('!Y-m-d H:i:s', $value);
     4713        $errors = DateTime::getLastErrors();
     4714        return $date instanceof DateTime
     4715            && ($errors === false || (empty($errors['warning_count']) && empty($errors['error_count'])))
     4716            && $date->format('Y-m-d H:i:s') === $value;
     4717    }
     4718
     4719    /** Accept only a JSON-safe scalar/array cursor that can be persisted and resumed. */
     4720    private function is_valid_generic_historical_cursor($cursor) {
     4721        if ($cursor === null || is_resource($cursor) || is_object($cursor)) {
     4722            return false;
     4723        }
     4724        $encoded = wp_json_encode($cursor);
     4725        return is_string($encoded) && strlen($encoded) <= 60000;
     4726    }
     4727
     4728    /**
     4729     * Discover overlap candidates fairly across source IDs. A global LIMIT can let
     4730     * a high-volume source crowd every other adapter out of the reconciliation set.
     4731     * The per-source fetch is bounded, then candidates are interleaved round-robin.
     4732     */
     4733    private function get_generic_reconciliation_candidates_by_source($mode, $criteria, $limit = 25) {
     4734        global $wpdb;
     4735
     4736        if (!is_array($criteria)) {
     4737            return ['status' => 'error', 'error' => 'Invalid reconciliation criteria.', 'events' => [], 'observed' => 0, 'truncated' => false];
     4738        }
     4739        if ($mode === 'fingerprint') {
     4740            $fingerprint = isset($criteria['fingerprint']) ? (string) $criteria['fingerprint'] : '';
     4741            $source_id = isset($criteria['source_id']) ? absint($criteria['source_id']) : 0;
     4742            if ($fingerprint === '' || !$source_id) {
     4743                return ['status' => 'error', 'error' => 'Invalid fingerprint reconciliation criteria.', 'events' => [], 'observed' => 0, 'truncated' => false];
     4744            }
     4745            $where_sql = 'events.source_fingerprint = %s AND events.source_id <> %d';
     4746            $where_args = [$fingerprint, $source_id];
     4747        } elseif ($mode === 'temporal') {
     4748            $source_id = isset($criteria['source_id']) ? absint($criteria['source_id']) : 0;
     4749            $product_id = isset($criteria['product_id']) ? absint($criteria['product_id']) : 0;
     4750            $period_start = isset($criteria['period_start']) ? (string) $criteria['period_start'] : '';
     4751            $period_end = isset($criteria['period_end']) ? (string) $criteria['period_end'] : '';
     4752            if (!$source_id || !$product_id || $period_start === '' || $period_end === '') {
     4753                return ['status' => 'error', 'error' => 'Invalid temporal reconciliation criteria.', 'events' => [], 'observed' => 0, 'truncated' => false];
     4754            }
     4755            $where_sql = 'events.source_id <> %d AND events.product_id = %d AND events.period_start <= %s AND events.period_end >= %s';
     4756            $where_args = [$source_id, $product_id, $period_end, $period_start];
     4757        } else {
     4758            return ['status' => 'error', 'error' => 'Unsupported reconciliation mode.', 'events' => [], 'observed' => 0, 'truncated' => false];
     4759        }
     4760        $limit = max(1, min(25, absint($limit)));
     4761        $reconciliation_sql = "SELECT DISTINCT events.source_id
     4762             FROM {$this->view_source_events_table} AS events
     4763             WHERE {$where_sql}
     4764             ORDER BY events.source_id ASC";
     4765        // The criteria branches above define either four temporal placeholders
     4766        // or two exact-match placeholders; prepare receives the matching values.
     4767        if ($mode === 'temporal') {
     4768// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     4769            $source_ids = statpl_db_get_col(statpl_db_prepare(
     4770                /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $reconciliation_sql,
     4771                $source_id,
     4772                $product_id,
     4773                $period_end,
     4774                $period_start
     4775            ));
     4776        } else {
     4777// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     4778            $source_ids = statpl_db_get_col(statpl_db_prepare(
     4779                /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $reconciliation_sql,
     4780                $fingerprint,
     4781                $source_id
     4782            ));
     4783        }
     4784        if (!empty($wpdb->last_error)) {
     4785            return ['status' => 'error', 'error' => (string) $wpdb->last_error, 'events' => [], 'observed' => 0, 'truncated' => false];
     4786        }
     4787
     4788        $per_source = [];
     4789        $observed = 0;
     4790        $truncated = false;
     4791        foreach ((array) $source_ids as $candidate_source_id) {
     4792            $candidate_source_id = absint($candidate_source_id);
     4793            if (!$candidate_source_id) {
     4794                continue;
     4795            }
     4796            $rows = statpl_db_get_results(statpl_db_prepare(
     4797                "SELECT events.id, events.source_id, events.source_record_key,
     4798                        sources.source_key AS candidate_source_key,
     4799                        sources.adapter_key AS candidate_adapter_key, events.product_id,
     4800                        events.record_type, events.observed_views, events.period_start,
     4801                        events.period_end, events.applied_views
     4802                 FROM {$this->view_source_events_table} AS events
     4803                 LEFT JOIN {$this->view_sources_table} AS sources ON sources.id = events.source_id
     4804                 WHERE ({$where_sql}) AND events.source_id = %d
     4805                 ORDER BY events.period_start ASC, events.period_end ASC, events.id ASC
     4806                 LIMIT 26 FOR UPDATE",
     4807                array_merge($where_args, [$candidate_source_id])
     4808            ), ARRAY_A);
     4809            if (!empty($wpdb->last_error)) {
     4810                return ['status' => 'error', 'error' => (string) $wpdb->last_error, 'events' => [], 'observed' => $observed, 'truncated' => $truncated];
     4811            }
     4812            if (count($rows) > 25) {
     4813                $truncated = true;
     4814                $rows = array_slice($rows, 0, 25);
     4815            }
     4816            $observed += count($rows);
     4817            if ($rows) {
     4818                $per_source[$candidate_source_id] = array_values($rows);
     4819            }
     4820        }
     4821
     4822        $events = [];
     4823        $round = 0;
     4824        do {
     4825            $added_this_round = false;
     4826            foreach ($per_source as $source_rows) {
     4827                if (isset($source_rows[$round])) {
     4828                    $events[] = $source_rows[$round];
     4829                    $added_this_round = true;
     4830                    if (count($events) > $limit) {
     4831                        $truncated = true;
     4832                        break 2;
     4833                    }
     4834                }
     4835            }
     4836            $round++;
     4837        } while ($added_this_round);
     4838
     4839        if ($observed > count($events)) {
     4840            $truncated = true;
     4841        }
     4842        return [
     4843            'status' => 'ok',
     4844            'events' => array_slice($events, 0, $limit),
     4845            'observed' => $observed,
     4846            'truncated' => $truncated,
     4847        ];
     4848    }
     4849
     4850    /**
     4851     * Build a source-wide, deterministic overlap profile for the current record.
     4852     * This is deliberately descriptive: timestamps and aggregate periods are not
     4853     * proof that two providers observed the same pageview. The profile gives the
     4854     * later reconciliation pass comparable signals without changing applied_views.
     4855     */
     4856    /**
     4857     * Return declared source capabilities for reconciliation review.
     4858     * This is a descriptive capability inventory, not a numeric reliability score.
     4859     * Missing declarations remain unknown; they are never inferred from plugin names.
     4860     */
     4861    private function get_generic_source_capability_profiles($source_ids) {
     4862        global $wpdb;
     4863
     4864        $source_ids = array_values(array_unique(array_filter(array_map('absint', (array) $source_ids))));
     4865        if (!$source_ids) {
     4866            return ['status' => 'empty', 'sources' => [], 'requested_source_count' => 0];
     4867        }
     4868
     4869        // The list is built only from normalized positive integer source IDs.
     4870        // A fixed-size batch limit keeps the SQL placeholder count bounded.
     4871        $placeholders = implode(',', array_fill(0, count($source_ids), '%d'));
     4872        // Start from the source registry and LEFT JOIN origins. A persisted source
     4873        // with no origin row must remain visible as an unknown-capability source;
     4874        // an INNER JOIN would silently omit it from the reconciliation inventory.
     4875        $inventory_sql = "SELECT s.id AS source_id, s.source_key, s.display_name,
     4876                    s.adapter_key AS source_adapter_key, s.granularity AS source_granularity,
     4877                    o.origin_key, o.adapter_key, o.capabilities,
     4878                    o.detection_status
     4879             FROM {$this->view_sources_table} AS s
     4880             LEFT JOIN {$this->view_source_origins_table} AS o ON o.source_id = s.id
     4881             WHERE s.id IN ({$placeholders})
     4882             ORDER BY s.id ASC, o.origin_key ASC";
     4883        // prepare() accepts an argument array as its second parameter. This keeps
     4884        // the dynamically sized placeholder list paired with its normalized IDs.
     4885// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     4886        $rows = statpl_db_get_results(statpl_db_prepare($inventory_sql, $source_ids), ARRAY_A);
     4887        if (!empty($wpdb->last_error) || !is_array($rows)) {
     4888            return [
     4889                'status' => 'query_failed',
     4890                'sources' => [],
     4891                'requested_source_count' => count($source_ids),
     4892                'error' => (string) $wpdb->last_error,
     4893            ];
     4894        }
     4895
     4896        $profiles = [];
     4897        foreach ($rows as $row) {
     4898            $id = absint($row['source_id'] ?? 0);
     4899            if (!$id) { continue; }
     4900            $profiles[$id] = $profiles[$id] ?? [
     4901                'source_id' => $id,
     4902                'source_key' => sanitize_key((string) ($row['source_key'] ?? '')),
     4903                'display_name' => sanitize_text_field((string) ($row['display_name'] ?? '')),
     4904                'source_adapter_key' => sanitize_key((string) ($row['source_adapter_key'] ?? '')),
     4905                'source_granularity' => sanitize_key((string) ($row['source_granularity'] ?? 'unknown')),
     4906                'origin_profile_status' => 'available',
     4907                'origins' => [],
     4908            ];
     4909
     4910            // A NULL origin row is meaningful: the source exists, but no declared
     4911            // origin capabilities are available. Do not infer them from its name.
     4912            if ($row['origin_key'] === null || $row['origin_key'] === '') {
     4913                $profiles[$id]['origin_profile_status'] = 'no_origin_declarations';
     4914                continue;
     4915            }
     4916
     4917            $declared = json_decode((string) ($row['capabilities'] ?? ''), true);
     4918            if (!is_array($declared)) { $declared = []; }
     4919
     4920            $granularity = sanitize_key((string) ($declared['granularity'] ?? 'unknown'));
     4921            $precision = sanitize_key((string) ($declared['timestamp_precision'] ?? 'unknown'));
     4922            $storage_granularity = sanitize_key((string) ($declared['source_storage_granularity'] ?? 'unknown'));
     4923            $profiles[$id]['origins'][] = [
     4924                'origin_key' => sanitize_key((string) ($row['origin_key'] ?? '')),
     4925                'adapter_key' => sanitize_key((string) ($row['adapter_key'] ?? '')),
     4926                'detection_status' => sanitize_key((string) ($row['detection_status'] ?? 'unknown')),
     4927                'granularity' => $granularity,
     4928                'timestamp_precision' => $precision,
     4929                'source_storage_granularity' => $storage_granularity,
     4930                'product_id_direct' => array_key_exists('product_id_direct', $declared) ? (bool) $declared['product_id_direct'] : null,
     4931                'target_requires_product_resolution' => array_key_exists('target_requires_product_resolution', $declared) ? (bool) $declared['target_requires_product_resolution'] : null,
     4932                'stable_event_identity_declared' => array_key_exists('stable_event_identity', $declared) ? (bool) $declared['stable_event_identity'] : null,
     4933                'bot_filtered' => array_key_exists('bot_filtered', $declared) ? (bool) $declared['bot_filtered'] : null,
     4934                'immutable_past_days' => array_key_exists('immutable_past_days', $declared) ? (bool) $declared['immutable_past_days'] : null,
     4935                'capability_comparability' => [
     4936                    'event_level' => $granularity === 'event',
     4937                    'second_precision' => $precision === 'seconds',
     4938                    'direct_product_mapping' => !empty($declared['product_id_direct']),
     4939                    'aggregate_only_or_unknown' => in_array($granularity, ['daily_aggregate', 'hourly_aggregate', 'unknown'], true),
     4940                ],
     4941            ];
     4942        }
     4943
     4944        // Preserve requested IDs that have no matching source-registry row as
     4945        // explicit unresolved entries instead of silently shrinking the scope.
     4946        foreach ($source_ids as $requested_id) {
     4947            if (!isset($profiles[$requested_id])) {
     4948                $profiles[$requested_id] = [
     4949                    'source_id' => $requested_id,
     4950                    'source_key' => '',
     4951                    'display_name' => '',
     4952                    'source_adapter_key' => '',
     4953                    'source_granularity' => 'unknown',
     4954                    'origin_profile_status' => 'source_registry_row_missing',
     4955                    'origins' => [],
     4956                ];
     4957            }
     4958        }
     4959        ksort($profiles, SORT_NUMERIC);
     4960
     4961        $resolved_count = 0;
     4962        $without_origin_count = 0;
     4963        foreach ($profiles as $profile) {
     4964            if (($profile['origin_profile_status'] ?? '') === 'source_registry_row_missing') {
     4965                continue;
     4966            }
     4967            $resolved_count++;
     4968            if (($profile['origin_profile_status'] ?? '') === 'no_origin_declarations') {
     4969                $without_origin_count++;
     4970            }
     4971        }
     4972        $status = $resolved_count < count($source_ids)
     4973            ? 'partial'
     4974            : ($without_origin_count > 0 ? 'partial' : 'available');
     4975
     4976        return [
     4977            'status' => $status,
     4978            'sources' => array_values($profiles),
     4979            'requested_source_count' => count($source_ids),
     4980            'resolved_source_count' => $resolved_count,
     4981            'sources_without_origin_declarations' => $without_origin_count,
     4982        ];
     4983    }
     4984
     4985    private function build_generic_cross_source_overlap_assessment($product_id, $source_id, $adapter_key, $record_type, $period_start, $period_end, $candidates, $truncated = false, $observed_views = 0) {
     4986        $product_id = absint($product_id);
     4987        $record_type = sanitize_key((string) $record_type);
     4988        $period_start = $period_start !== null ? (string) $period_start : '';
     4989        $period_end = $period_end !== null ? (string) $period_end : '';
     4990        $sources = [];
     4991        $same_instant_event_candidates = 0;
     4992        $same_bounds_aggregate_candidates = 0;
     4993        $intersecting_aggregate_candidates = 0;
     4994        $different_type_candidates = 0;
     4995
     4996        foreach ((array) $candidates as $candidate) {
     4997            if (!is_array($candidate) || (int) ($candidate['product_id'] ?? 0) !== $product_id) {
     4998                continue;
     4999            }
     5000            $candidate_source_id = absint($candidate['source_id'] ?? 0);
     5001            if (!$candidate_source_id) {
     5002                continue;
     5003            }
     5004            $candidate_type = sanitize_key((string) ($candidate['record_type'] ?? ''));
     5005            $candidate_start = isset($candidate['period_start']) ? (string) $candidate['period_start'] : '';
     5006            $candidate_end = isset($candidate['period_end']) ? (string) $candidate['period_end'] : '';
     5007            $same_bounds = $period_start !== '' && $period_end !== ''
     5008                && $candidate_start === $period_start && $candidate_end === $period_end;
     5009            $intersects = $period_start !== '' && $period_end !== ''
     5010                && $candidate_start !== '' && $candidate_end !== ''
     5011                && $candidate_start <= $period_end && $candidate_end >= $period_start;
     5012
     5013            if (!isset($sources[$candidate_source_id])) {
     5014                $sources[$candidate_source_id] = [
     5015                    'source_id' => $candidate_source_id,
     5016                    'source_key' => isset($candidate['candidate_source_key']) ? (string) $candidate['candidate_source_key'] : null,
     5017                    'adapter_key' => isset($candidate['candidate_adapter_key']) ? (string) $candidate['candidate_adapter_key'] : null,
     5018                    'record_types' => [],
     5019                    'candidate_records' => 0,
     5020                ];
     5021            }
     5022            $sources[$candidate_source_id]['candidate_records']++;
     5023            if ($candidate_type !== '') {
     5024                $sources[$candidate_source_id]['record_types'][$candidate_type] = true;
     5025            }
     5026
     5027            if ($candidate_type !== $record_type) {
     5028                $different_type_candidates++;
     5029            }
     5030            if ($record_type === 'event' && $candidate_type === 'event' && $same_bounds) {
     5031                $same_instant_event_candidates++;
     5032            } elseif ($record_type === 'aggregate' && $candidate_type === 'aggregate' && $same_bounds) {
     5033                $same_bounds_aggregate_candidates++;
     5034            } elseif ($intersects && ($record_type === 'aggregate' || $candidate_type === 'aggregate')) {
     5035                $intersecting_aggregate_candidates++;
     5036            }
     5037        }
     5038
     5039        foreach ($sources as &$source) {
     5040            $source['record_types'] = array_keys($source['record_types']);
     5041            sort($source['record_types'], SORT_STRING);
     5042        }
     5043        unset($source);
     5044        ksort($sources, SORT_NUMERIC);
     5045
     5046        // Identical naive datetime labels cannot establish a shared temporal
     5047        // bucket without persisted source-timezone provenance; no count range is inferred.
     5048        $same_bucket_range = [
     5049            'status' => 'unavailable',
     5050            'reason' => 'timezone_basis_not_persisted',
     5051            'minimum_unique_views' => null,
     5052            'maximum_unique_views' => null,
     5053            'minimum_possible_duplicate_views' => null,
     5054            'maximum_possible_duplicate_views' => null,
     5055            'source_contributions' => [],
     5056        ];
     5057
     5058        $capability_source_ids = array_merge([absint($source_id)], array_keys($sources));
     5059        $capability_profiles = $this->get_generic_source_capability_profiles($capability_source_ids);
     5060
     5061        return [
     5062            'version' => '0.2.0',
     5063            'scope' => 'same_product_different_sources',
     5064            'incoming_source' => [
     5065                'source_id' => absint($source_id),
     5066                'adapter_key' => sanitize_key((string) $adapter_key),
     5067                'record_type' => $record_type,
     5068            ],
     5069            'candidate_sources' => array_values($sources),
     5070            'source_capability_profiles' => $capability_profiles,
     5071            'reliability_policy' => 'declared_capabilities_only_no_numeric_score_or_inferred_trust_rank',
     5072            'candidate_source_count' => count($sources),
     5073            'candidate_record_count' => array_sum(array_column(array_values($sources), 'candidate_records')),
     5074            'same_instant_event_candidates' => $same_instant_event_candidates,
     5075            'same_bounds_aggregate_candidates' => $same_bounds_aggregate_candidates,
     5076            'intersecting_aggregate_candidates' => $intersecting_aggregate_candidates,
     5077            'period_comparison_semantics' => 'raw_datetime_string_representation_only_timezone_unverified',
     5078            'different_record_type_candidates' => $different_type_candidates,
     5079            'candidate_set_truncated' => (bool) $truncated,
     5080            'identical_bucket_count_range' => $same_bucket_range,
     5081            'identity_confidence' => 'unproven_without_shared_event_identity',
     5082            'temporal_basis_status' => 'timezone_provenance_not_persisted',
     5083            'absolute_temporal_comparison_performed' => false,
     5084            'counting_decision' => 'preserve_contribution_pending_global_reconciliation',
     5085            'policy' => 'Stored datetime strings and identical period bounds are representation-level signals only because source timezone provenance is not persisted; no absolute-time overlap or unique-view range is asserted and no contribution is suppressed by this assessment.',
     5086        ];
     5087    }
     5088
     5089    /**
     5090     * Build a bounded product-wide inventory across every persisted generic source.
     5091     * This is a descriptive synthesis of recorded contributions, not a unique-view
     5092     * estimate: source totals are never added, ranked, or suppressed here.
     5093     */
     5094    private function build_generic_product_source_synthesis($product_id) {
     5095        global $wpdb;
     5096
     5097        $product_id = absint($product_id);
     5098        if (!$product_id) {
     5099            return ['status' => 'unavailable', 'reason' => 'invalid_product_id', 'sources' => []];
     5100        }
     5101
     5102        // Keep the source-ID inventory independent of the bounded record-group
     5103        // result so capability profiles cover every source, even when the
     5104        // descriptive record inventory reaches its output limit.
     5105        $source_id_rows = statpl_db_get_col(statpl_db_prepare(
     5106            "SELECT DISTINCT source_id
     5107             FROM {$this->view_source_events_table}
     5108             WHERE product_id = %d
     5109             ORDER BY source_id ASC",
     5110            $product_id
     5111        ));
     5112        if (!empty($wpdb->last_error) || !is_array($source_id_rows)) {
     5113            return ['status' => 'query_failed', 'reason' => 'product_source_id_inventory_query_failed', 'sources' => []];
     5114        }
     5115        $all_source_ids = array_values(array_unique(array_filter(array_map('absint', $source_id_rows))));
     5116
     5117        $rows = statpl_db_get_results(statpl_db_prepare(
     5118            "SELECT e.source_id, s.source_key, s.display_name, e.record_type,
     5119                    COUNT(*) AS record_count, SUM(e.observed_views) AS observed_views,
     5120                    SUM(e.applied_views) AS applied_views,
     5121                    MIN(e.period_start) AS first_period_start,
     5122                    MAX(e.period_end) AS last_period_end
     5123             FROM {$this->view_source_events_table} AS e
     5124             LEFT JOIN {$this->view_sources_table} AS s ON s.id = e.source_id
     5125             WHERE e.product_id = %d
     5126             GROUP BY e.source_id, s.source_key, s.display_name, e.record_type
     5127             ORDER BY e.source_id ASC, e.record_type ASC
     5128             LIMIT 101",
     5129            $product_id
     5130        ), ARRAY_A);
     5131        if (!empty($wpdb->last_error) || !is_array($rows)) {
     5132            return ['status' => 'query_failed', 'reason' => 'product_source_inventory_query_failed', 'sources' => []];
     5133        }
     5134        $truncated = count($rows) > 100;
     5135        if ($truncated) {
     5136            $rows = array_slice($rows, 0, 100);
     5137        }
     5138        $sources = [];
     5139        foreach ($rows as $row) {
     5140            $source_id = absint($row['source_id'] ?? 0);
     5141            if (!$source_id) { continue; }
     5142            if (!isset($sources[$source_id])) {
     5143                $sources[$source_id] = [
     5144                    'source_id' => $source_id,
     5145                    'source_key' => sanitize_key((string) ($row['source_key'] ?? '')),
     5146                    'display_name' => sanitize_text_field((string) ($row['display_name'] ?? '')),
     5147                    'record_types' => [],
     5148                ];
     5149            }
     5150            $sources[$source_id]['record_types'][] = [
     5151                'record_type' => sanitize_key((string) ($row['record_type'] ?? 'unknown')),
     5152                'record_count' => max(0, (int) ($row['record_count'] ?? 0)),
     5153                'observed_views' => max(0, (int) ($row['observed_views'] ?? 0)),
     5154                'applied_views' => max(0, (int) ($row['applied_views'] ?? 0)),
     5155                'first_period_start' => $row['first_period_start'] !== null ? (string) $row['first_period_start'] : null,
     5156                'last_period_end' => $row['last_period_end'] !== null ? (string) $row['last_period_end'] : null,
     5157            ];
     5158        }
     5159        ksort($sources, SORT_NUMERIC);
     5160
     5161        $capability_profiles = $this->get_generic_source_capability_profiles($all_source_ids);
     5162        $capability_profiles_by_source = [];
     5163        // A partial capability lookup still contains useful, successfully
     5164        // resolved profiles plus explicit unresolved entries. Retain every
     5165        // returned profile; the status separately communicates incompleteness.
     5166        if (in_array(($capability_profiles['status'] ?? ''), ['available', 'partial'], true)) {
     5167            foreach ((array) ($capability_profiles['sources'] ?? []) as $profile) {
     5168                $profile_source_id = absint($profile['source_id'] ?? 0);
     5169                if ($profile_source_id) {
     5170                    $capability_profiles_by_source[$profile_source_id] = $profile;
     5171                }
     5172            }
     5173        }
     5174
     5175        return [
     5176            'status' => 'available',
     5177            'scope' => 'all_persisted_generic_sources_for_product',
     5178            'sources' => array_values($sources),
     5179            'source_ids_in_scope' => $all_source_ids,
     5180            'source_count_in_scope' => count($all_source_ids),
     5181            'source_capability_profiles' => array_values($capability_profiles_by_source),
     5182            'capability_profile_status' => sanitize_key((string) ($capability_profiles['status'] ?? 'unknown')),
     5183            'capability_profile_source_count' => count($capability_profiles_by_source),
     5184            'source_record_groups_truncated' => $truncated,
     5185            'unique_view_total' => null,
     5186            'counting_decision' => 'no_cross_source_sum_or_suppression',
     5187            'policy' => 'Observed and applied contributions are reported separately by source and record type; declared capabilities are attached for every source in scope where available, without numeric reliability ranking or a deduplicated product-view total.',
     5188        ];
     5189    }
     5190
     5191    /**
     5192     * Combine the independently collected reconciliation evidence into one
     5193     * product-level audit summary. This is classification only: it never
     5194     * estimates unique views, changes applied_views, or authorizes suppression.
     5195     */
     5196    /**
     5197     * Classify the completeness and ambiguity of the retained cross-source candidate set.
     5198     * Candidate ordering identifies an audit primary only; it never establishes identity.
     5199     */
     5200    private function build_generic_candidate_set_assessment($candidates, $candidates_truncated = false, $observed_count = null) {
     5201        $candidates = is_array($candidates) ? $candidates : [];
     5202        $by_id = [];
     5203        $source_ids = [];
     5204        foreach ($candidates as $candidate) {
     5205            if (!is_array($candidate)) {
     5206                continue;
     5207            }
     5208            $event_id = isset($candidate['id']) ? (int) $candidate['id'] : 0;
     5209            $source_id = isset($candidate['source_id']) ? (int) $candidate['source_id'] : 0;
     5210            if ($event_id > 0) {
     5211                $by_id[$event_id] = true;
     5212            }
     5213            if ($source_id > 0) {
     5214                $source_ids[$source_id] = true;
     5215            }
     5216        }
     5217        $candidate_count = count($by_id);
     5218        $source_count = count($source_ids);
     5219        $observed_count = $observed_count === null ? $candidate_count : max($candidate_count, (int) $observed_count);
     5220        $truncated = (bool) $candidates_truncated;
     5221
     5222        if ($candidate_count > 1 || $truncated || $observed_count > $candidate_count) {
     5223            $classification = 'multiple_or_truncated_candidates_require_review';
     5224        } elseif ($candidate_count === 1) {
     5225            $classification = 'single_candidate_requires_review';
     5226        } else {
     5227            $classification = 'no_candidate_available';
     5228        }
     5229
     5230        return [
     5231            'classification' => $classification,
     5232            'candidate_count_retained' => $candidate_count,
     5233            'candidate_event_ids' => array_map('intval', array_keys($by_id)),
     5234            'candidate_source_ids_by_event_id' => array_intersect_key(array_reduce($candidates, static function ($carry, $candidate) {
     5235                if (is_array($candidate) && !empty($candidate['id']) && !empty($candidate['source_id'])) {
     5236                    $carry[(int) $candidate['id']] = (int) $candidate['source_id'];
     5237                }
     5238                return $carry;
     5239            }, []), $by_id),
     5240            'candidate_count_observed' => $observed_count,
     5241            'candidate_source_count_retained' => $source_count,
     5242            'multiple_candidate_ambiguity' => $candidate_count > 1 || $observed_count > 1,
     5243            'multiple_source_ambiguity' => $source_count > 1,
     5244            'candidate_set_truncated' => $truncated,
     5245            'candidate_count_is_lower_bound' => $truncated,
     5246            'primary_candidate_is_identity_proof' => false,
     5247            'automatic_suppression_authorized' => false,
     5248            'policy' => 'All retained candidates remain evidence. Primary ordering is for audit navigation only and does not resolve identity or authorize count suppression.',
     5249        ];
     5250    }
     5251
     5252    /**
     5253     * Describe conflicting or heterogeneous dimensions across retained candidates.
     5254     * Differences are classified as evidence only; this method never resolves identity
     5255     * or changes the contribution applied to product totals.
     5256     */
     5257    /**
     5258     * Validate the integrity of the per-candidate audit records. Missing or duplicate
     5259     * identifiers and malformed classifications are retained as audit errors; this
     5260     * method never infers identity or authorizes suppression.
     5261     */
     5262    private function build_generic_candidate_record_integrity_assessment($candidate_details, $expected_unique_candidate_count) {
     5263        $candidate_details = is_array($candidate_details) ? $candidate_details : [];
     5264        $expected_unique_candidate_count = max(0, (int) $expected_unique_candidate_count);
     5265        $errors = [];
     5266        $seen_ids = [];
     5267        $missing_id_count = 0;
     5268        $duplicate_id_count = 0;
     5269        $invalid_classification_count = 0;
     5270        $invalid_dimension_check_count = 0;
     5271        $allowed_classifications = [
     5272            'fingerprint_dimension_mismatch',
     5273            'temporal_candidate_different_record_shape',
     5274            'candidate_dimensions_match_without_identity_proof',
     5275            'insufficient_candidate_dimensions',
     5276        ];
     5277        $required_dimensions = ['product_id', 'record_type', 'observed_views', 'period_start', 'period_end'];
     5278
     5279        foreach ($candidate_details as $detail) {
     5280            if (!is_array($detail)) {
     5281                $missing_id_count++;
     5282                $invalid_classification_count++;
     5283                $invalid_dimension_check_count++;
     5284                continue;
     5285            }
     5286            $candidate_id = absint($detail['candidate_event_id'] ?? 0);
     5287            if (!$candidate_id) {
     5288                $missing_id_count++;
     5289            } elseif (isset($seen_ids[$candidate_id])) {
     5290                $duplicate_id_count++;
     5291            } else {
     5292                $seen_ids[$candidate_id] = true;
     5293            }
     5294            if (!in_array($detail['classification'] ?? null, $allowed_classifications, true)) {
     5295                $invalid_classification_count++;
     5296            }
     5297            $checks = $detail['dimension_checks'] ?? null;
     5298            $checks_valid = is_array($checks);
     5299            if ($checks_valid) {
     5300                foreach ($required_dimensions as $dimension) {
     5301                    if (!array_key_exists($dimension, $checks)
     5302                        || !in_array($checks[$dimension], [true, false, null], true)) {
     5303                        $checks_valid = false;
     5304                        break;
     5305                    }
     5306                }
     5307            }
     5308            if (!$checks_valid) {
     5309                $invalid_dimension_check_count++;
     5310            }
     5311        }
     5312
     5313        $detail_count = count($candidate_details);
     5314        $unique_id_count = count($seen_ids);
     5315        if ($missing_id_count > 0) {
     5316            $errors[] = 'candidate_audit_record_missing_valid_event_id';
     5317        }
     5318        if ($duplicate_id_count > 0) {
     5319            $errors[] = 'candidate_audit_records_contain_duplicate_event_ids';
     5320        }
     5321        if ($invalid_classification_count > 0) {
     5322            $errors[] = 'candidate_audit_record_has_invalid_classification';
     5323        }
     5324        if ($invalid_dimension_check_count > 0) {
     5325            $errors[] = 'candidate_audit_record_has_invalid_dimension_checks';
     5326        }
     5327        if ($detail_count !== $expected_unique_candidate_count || $unique_id_count !== $expected_unique_candidate_count) {
     5328            $errors[] = 'candidate_audit_record_count_does_not_match_unique_candidate_inventory';
     5329        }
     5330
     5331        return [
     5332            'status' => $errors ? 'candidate_records_require_review' : 'candidate_records_valid',
     5333            'consistent' => !$errors,
     5334            'errors' => array_values(array_unique($errors)),
     5335            'candidate_detail_count' => $detail_count,
     5336            'unique_valid_candidate_id_count' => $unique_id_count,
     5337            'expected_unique_candidate_count' => $expected_unique_candidate_count,
     5338            'missing_id_count' => $missing_id_count,
     5339            'duplicate_id_count' => $duplicate_id_count,
     5340            'invalid_classification_count' => $invalid_classification_count,
     5341            'invalid_dimension_check_count' => $invalid_dimension_check_count,
     5342            'identity_proven' => false,
     5343            'automatic_suppression_authorized' => false,
     5344            'policy' => 'Record validation is audit-only. Invalid records remain visible for review and cannot establish identity or authorize suppression.',
     5345        ];
     5346    }
     5347
     5348    private function build_generic_candidate_conflict_assessment($incoming, $candidates) {
     5349        $incoming = is_array($incoming) ? $incoming : [];
     5350        $candidates = is_array($candidates) ? $candidates : [];
     5351        $counts = [
     5352            'fingerprint_dimension_mismatch' => 0,
     5353            'temporal_candidate_different_record_shape' => 0,
     5354            'candidate_dimensions_match_without_identity_proof' => 0,
     5355            'insufficient_candidate_dimensions' => 0,
     5356        ];
     5357        $candidate_ids = [];
     5358        $dimension_signatures = [];
     5359        $details = [];
     5360        foreach ($candidates as $candidate) {
     5361            if (!is_array($candidate)) {
     5362                continue;
     5363            }
     5364            $candidate_id = absint($candidate['id'] ?? 0);
     5365            if ($candidate_id) {
     5366                $candidate_ids[$candidate_id] = true;
     5367            }
     5368            $discovered_by = array_values((array) ($candidate['_discovered_by'] ?? []));
     5369            $has_fingerprint_signal = in_array('fingerprint', $discovered_by, true);
     5370            $has_temporal_signal = in_array('temporal', $discovered_by, true);
     5371            $checks = [
     5372                'product_id' => isset($incoming['product_id'], $candidate['product_id'])
     5373                    ? (int) $incoming['product_id'] === (int) $candidate['product_id'] : null,
     5374                'record_type' => isset($incoming['record_type'], $candidate['record_type'])
     5375                    ? (string) $incoming['record_type'] === (string) $candidate['record_type'] : null,
     5376                'observed_views' => isset($incoming['observed_views'], $candidate['observed_views'])
     5377                    ? (int) $incoming['observed_views'] === (int) $candidate['observed_views'] : null,
     5378                'period_start' => !empty($incoming['period_start']) && !empty($candidate['period_start'])
     5379                    ? (string) $incoming['period_start'] === (string) $candidate['period_start'] : null,
     5380                'period_end' => !empty($incoming['period_end']) && !empty($candidate['period_end'])
     5381                    ? (string) $incoming['period_end'] === (string) $candidate['period_end'] : null,
     5382            ];
     5383            $known_checks = array_filter($checks, static function ($value) { return $value !== null; });
     5384            $all_known_dimensions_match = count($known_checks) === count($checks)
     5385                && !in_array(false, $known_checks, true);
     5386            $has_dimension_difference = in_array(false, $known_checks, true);
     5387            if ($has_fingerprint_signal && $has_dimension_difference) {
     5388                $classification = 'fingerprint_dimension_mismatch';
     5389            } elseif ($has_temporal_signal && (
     5390                $checks['record_type'] === false || $checks['observed_views'] === false
     5391            )) {
     5392                $classification = 'temporal_candidate_different_record_shape';
     5393            } elseif ($all_known_dimensions_match) {
     5394                $classification = 'candidate_dimensions_match_without_identity_proof';
     5395            } else {
     5396                $classification = 'insufficient_candidate_dimensions';
     5397            }
     5398            $counts[$classification]++;
     5399            $signature = implode('|', [
     5400                (string) ($candidate['product_id'] ?? 'unknown'),
     5401                (string) ($candidate['record_type'] ?? 'unknown'),
     5402                (string) ($candidate['observed_views'] ?? 'unknown'),
     5403                (string) ($candidate['period_start'] ?? ''),
     5404                (string) ($candidate['period_end'] ?? ''),
     5405            ]);
     5406            $dimension_signatures[$signature] = true;
     5407            $details[] = [
     5408                'candidate_event_id' => $candidate_id ?: null,
     5409                'candidate_source_id' => !empty($candidate['source_id']) ? (int) $candidate['source_id'] : null,
     5410                'discovered_by' => $discovered_by,
     5411                'classification' => $classification,
     5412                'dimension_checks' => $checks,
     5413                'identity_proven' => false,
     5414                'automatic_suppression_authorized' => false,
     5415            ];
     5416        }
     5417        $candidate_count = count($candidate_ids);
     5418        $record_integrity = $this->build_generic_candidate_record_integrity_assessment($details, $candidate_count);
     5419        $heterogeneous = count($dimension_signatures) > 1;
     5420        $has_conflict = $counts['fingerprint_dimension_mismatch'] > 0
     5421            || $counts['temporal_candidate_different_record_shape'] > 0;
     5422        return [
     5423            'status' => $candidate_count ? 'available' : 'no_candidates',
     5424            'candidate_count' => $candidate_count,
     5425            'candidate_dimension_signature_count' => count($dimension_signatures),
     5426            'candidate_set_is_heterogeneous' => $heterogeneous,
     5427            'conflicting_evidence_present' => $has_conflict,
     5428            'classification_counts' => $counts,
     5429            'candidate_record_integrity' => $record_integrity,
     5430            'candidates' => $details,
     5431            'identity_proven' => false,
     5432            'automatic_suppression_authorized' => false,
     5433            'policy' => 'Dimension differences and heterogeneous candidates are retained as review evidence only; they do not establish identity, select a winning candidate, or authorize count suppression.',
     5434        ];
     5435    }
     5436
     5437    /**
     5438     * Make an explicit audit decision from candidate support and contradiction evidence.
     5439     * Absence of candidates is not evidence that sources do not overlap. A clean dimensional
     5440     * comparison is not identity proof, and truncation always limits the conclusion.
     5441     */
     5442    private function build_generic_candidate_evidence_decision($conflict_assessment, $candidate_set_assessment = [], $candidate_search_truncated = false) {
     5443        $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : [];
     5444        $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : [];
     5445        $counts = (array) ($conflict_assessment['classification_counts'] ?? []);
     5446        $fingerprint_conflicts = max(0, (int) ($counts['fingerprint_dimension_mismatch'] ?? 0));
     5447        $temporal_shape_conflicts = max(0, (int) ($counts['temporal_candidate_different_record_shape'] ?? 0));
     5448        $matching_dimensions = max(0, (int) ($counts['candidate_dimensions_match_without_identity_proof'] ?? 0));
     5449        $insufficient_dimensions = max(0, (int) ($counts['insufficient_candidate_dimensions'] ?? 0));
     5450        $candidate_count = max(0, (int) ($conflict_assessment['candidate_count'] ?? 0));
     5451        $truncated = (bool) $candidate_search_truncated
     5452            || !empty($candidate_set_assessment['candidate_set_truncated'])
     5453            || !empty($candidate_set_assessment['candidate_count_is_lower_bound']);
     5454
     5455        $support = [];
     5456        $contradictions = [];
     5457        $limitations = [];
     5458        if ($matching_dimensions > 0) {
     5459            $support[] = 'candidate_dimensions_match_but_do_not_prove_shared_identity';
     5460        }
     5461        if ($fingerprint_conflicts > 0) {
     5462            $contradictions[] = 'fingerprint_discovery_signal_has_dimension_mismatches';
     5463        }
     5464        if ($temporal_shape_conflicts > 0) {
     5465            $contradictions[] = 'temporal_candidates_have_different_record_shapes';
     5466        }
     5467        if ($insufficient_dimensions > 0) {
     5468            $limitations[] = 'candidate_dimensions_are_incomplete';
     5469        }
     5470        if ($truncated) {
     5471            $limitations[] = 'candidate_search_truncated_conclusion_is_incomplete';
     5472        }
     5473
     5474        if ($candidate_count === 0) {
     5475            $classification = $truncated
     5476                ? 'candidate_search_incomplete_no_retained_candidate_no_overlap_conclusion'
     5477                : 'no_candidate_found_within_search_bounds_overlap_remains_unknown';
     5478        } elseif ($fingerprint_conflicts > 0 || $temporal_shape_conflicts > 0) {
     5479            $classification = ($matching_dimensions > 0)
     5480                ? 'supporting_and_contradictory_evidence_require_review'
     5481                : 'contradictory_candidate_evidence_requires_review';
     5482        } elseif ($matching_dimensions > 0) {
     5483            $classification = 'supportive_dimensions_without_identity_proof';
     5484        } else {
     5485            $classification = 'insufficient_candidate_evidence_requires_review';
     5486        }
     5487        if ($truncated && $candidate_count > 0) {
     5488            $limitations[] = 'retained_candidates_are_not_the_complete_observed_set';
     5489        }
     5490
     5491        return [
     5492            'classification' => $classification,
     5493            'supporting_evidence' => array_values(array_unique($support)),
     5494            'contradictory_evidence' => array_values(array_unique($contradictions)),
     5495            'limitations' => array_values(array_unique($limitations)),
     5496            'supporting_candidate_count' => $matching_dimensions,
     5497            'fingerprint_dimension_conflict_count' => $fingerprint_conflicts,
     5498            'temporal_record_shape_conflict_count' => $temporal_shape_conflicts,
     5499            'insufficient_dimension_candidate_count' => $insufficient_dimensions,
     5500            'candidate_count' => $candidate_count,
     5501            'candidate_search_truncated' => $truncated,
     5502            'absence_of_candidates_proves_no_overlap' => false,
     5503            'identity_proven' => false,
     5504            'automatic_suppression_authorized' => false,
     5505            'policy' => 'Support, contradiction, and missing evidence are reported separately. No candidate is not proof of no overlap; matching dimensions are not proof of identity; no evidence classification changes applied_views or authorizes suppression.',
     5506        ];
     5507    }
     5508
     5509    /**
     5510     * Validate that candidate inventory, conflict assessment, and evidence decision
     5511     * describe the same retained evidence and preserve the non-suppression policy.
     5512     * This is an audit integrity check only; it never changes reconciliation outcomes.
     5513     */
     5514    private function build_generic_candidate_audit_consistency($candidate_set_assessment, $conflict_assessment, $evidence_decision) {
     5515        $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : [];
     5516        $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : [];
     5517        $evidence_decision = is_array($evidence_decision) ? $evidence_decision : [];
     5518        $errors = [];
     5519        $candidate_count = max(0, (int) ($conflict_assessment['candidate_count'] ?? 0));
     5520        $retained_count = max(0, (int) ($candidate_set_assessment['candidate_count_retained'] ?? 0));
     5521        $decision_count = max(0, (int) ($evidence_decision['candidate_count'] ?? 0));
     5522        $counts = (array) ($conflict_assessment['classification_counts'] ?? []);
     5523        $record_integrity = $conflict_assessment['candidate_record_integrity'] ?? null;
     5524        $record_integrity = is_array($record_integrity) ? $record_integrity : [];
     5525        $classified_count = 0;
     5526        foreach ([
     5527            'fingerprint_dimension_mismatch',
     5528            'temporal_candidate_different_record_shape',
     5529            'candidate_dimensions_match_without_identity_proof',
     5530            'insufficient_candidate_dimensions',
     5531        ] as $key) {
     5532            $classified_count += max(0, (int) ($counts[$key] ?? 0));
     5533        }
     5534        if ($candidate_count !== $retained_count || $candidate_count !== $decision_count) {
     5535            $errors[] = 'candidate_counts_disagree_across_audit_layers';
     5536        }
     5537        if (empty($conflict_assessment['candidate_record_integrity']) || !is_array($conflict_assessment['candidate_record_integrity'])) {
     5538            $errors[] = 'candidate_record_integrity_assessment_missing_from_conflict_audit';
     5539        } elseif (empty($record_integrity['consistent'])) {
     5540            $errors[] = 'candidate_record_integrity_validation_failed';
     5541        }
     5542        if (isset($record_integrity['expected_unique_candidate_count'])
     5543            && (int) $record_integrity['expected_unique_candidate_count'] !== $candidate_count) {
     5544            $errors[] = 'candidate_record_integrity_expected_count_disagrees_with_conflict_inventory';
     5545        }
     5546        if ($classified_count > $candidate_count) {
     5547            $errors[] = 'candidate_classification_count_exceeds_candidate_inventory';
     5548        } elseif ($classified_count < $candidate_count) {
     5549            $errors[] = 'candidate_classification_coverage_incomplete';
     5550        }
     5551        $classification_coverage_complete = $classified_count === $candidate_count;
     5552        foreach ([
     5553            'supporting_candidate_count' => 'candidate_dimensions_match_without_identity_proof',
     5554            'fingerprint_dimension_conflict_count' => 'fingerprint_dimension_mismatch',
     5555            'temporal_record_shape_conflict_count' => 'temporal_candidate_different_record_shape',
     5556            'insufficient_dimension_candidate_count' => 'insufficient_candidate_dimensions',
     5557        ] as $decision_key => $conflict_key) {
     5558            if (max(0, (int) ($evidence_decision[$decision_key] ?? 0)) !== max(0, (int) ($counts[$conflict_key] ?? 0))) {
     5559                $errors[] = 'evidence_decision_count_mismatch_' . $decision_key;
     5560            }
     5561        }
     5562        $set_truncated = !empty($candidate_set_assessment['candidate_set_truncated']);
     5563        $decision_truncated = !empty($evidence_decision['candidate_search_truncated']);
     5564        if ($set_truncated && !$decision_truncated) {
     5565            $errors[] = 'candidate_truncation_not_propagated_to_evidence_decision';
     5566        }
     5567        if (!empty($conflict_assessment['identity_proven']) || !empty($evidence_decision['identity_proven'])) {
     5568            $errors[] = 'candidate_evidence_layer_claims_identity_proof';
     5569        }
     5570        if (!empty($candidate_set_assessment['automatic_suppression_authorized'])
     5571            || !empty($conflict_assessment['automatic_suppression_authorized'])
     5572            || !empty($evidence_decision['automatic_suppression_authorized'])) {
     5573            $errors[] = 'candidate_evidence_layer_authorizes_automatic_suppression';
     5574        }
     5575        if ($candidate_count === 0 && !empty($evidence_decision['absence_of_candidates_proves_no_overlap'])) {
     5576            $errors[] = 'absence_of_candidates_misrepresented_as_no_overlap_proof';
     5577        }
     5578        return [
     5579            'status' => $errors ? 'inconsistent_audit_evidence_requires_review' : 'candidate_audit_layers_consistent',
     5580            'consistent' => !$errors,
     5581            'errors' => array_values(array_unique($errors)),
     5582            'candidate_count_conflict_assessment' => $candidate_count,
     5583            'candidate_count_inventory' => $retained_count,
     5584            'candidate_count_evidence_decision' => $decision_count,
     5585            'classified_candidate_count' => $classified_count,
     5586            'unclassified_candidate_count' => max(0, $candidate_count - $classified_count),
     5587            'classification_coverage_complete' => $classification_coverage_complete,
     5588            'candidate_record_integrity' => [
     5589                'present' => !empty($conflict_assessment['candidate_record_integrity']) && is_array($conflict_assessment['candidate_record_integrity']),
     5590                'consistent' => !empty($record_integrity['consistent']),
     5591                'errors' => array_values((array) ($record_integrity['errors'] ?? [])),
     5592                'candidate_detail_count' => max(0, (int) ($record_integrity['candidate_detail_count'] ?? 0)),
     5593                'unique_valid_candidate_id_count' => max(0, (int) ($record_integrity['unique_valid_candidate_id_count'] ?? 0)),
     5594                'expected_unique_candidate_count' => max(0, (int) ($record_integrity['expected_unique_candidate_count'] ?? 0)),
     5595            ],
     5596            'candidate_search_truncated' => $decision_truncated,
     5597            'identity_proven' => false,
     5598            'automatic_suppression_authorized' => false,
     5599            'policy' => 'Consistency validation is audit-only. A failed validation requires review and never changes applied_views or authorizes count suppression.',
     5600        ];
     5601    }
     5602
     5603    /**
     5604     * Compare the event/source references retained by candidate inventory and
     5605     * per-candidate conflict records. Reference mismatches require review only.
     5606     */
     5607    private function build_generic_candidate_reference_consistency($candidate_set_assessment, $conflict_assessment) {
     5608        $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : [];
     5609        $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : [];
     5610        $inventory_ids = array_values(array_unique(array_filter(array_map('absint', (array) ($candidate_set_assessment['candidate_event_ids'] ?? [])))));
     5611        $detail_ids = [];
     5612        $detail_sources = [];
     5613        $duplicate_detail_ids = [];
     5614        foreach ((array) ($conflict_assessment['candidates'] ?? []) as $detail) {
     5615            if (!is_array($detail) || empty($detail['candidate_event_id'])) {
     5616                continue;
     5617            }
     5618            $id = absint($detail['candidate_event_id']);
     5619            if (isset($detail_ids[$id])) {
     5620                $duplicate_detail_ids[] = $id;
     5621            }
     5622            $detail_ids[$id] = true;
     5623            $detail_sources[$id] = isset($detail['candidate_source_id']) ? absint($detail['candidate_source_id']) : 0;
     5624        }
     5625        $inventory_set = array_fill_keys($inventory_ids, true);
     5626        $detail_set = $detail_ids;
     5627        $missing_details = array_values(array_diff($inventory_ids, array_keys($detail_set)));
     5628        $unexpected_details = array_values(array_diff(array_keys($detail_set), $inventory_ids));
     5629        $source_mismatches = [];
     5630        $inventory_sources = (array) ($candidate_set_assessment['candidate_source_ids_by_event_id'] ?? []);
     5631        foreach ($inventory_sources as $event_id => $source_id) {
     5632            $event_id = absint($event_id);
     5633            $source_id = absint($source_id);
     5634            if (isset($detail_sources[$event_id]) && $detail_sources[$event_id] !== $source_id) {
     5635                $source_mismatches[] = $event_id;
     5636            }
     5637        }
     5638        $errors = [];
     5639        if ($missing_details) { $errors[] = 'candidate_inventory_event_missing_conflict_detail'; }
     5640        if ($unexpected_details) { $errors[] = 'conflict_detail_event_missing_from_candidate_inventory'; }
     5641        if ($duplicate_detail_ids) { $errors[] = 'conflict_details_contain_duplicate_event_references'; }
     5642        if ($source_mismatches) { $errors[] = 'candidate_source_reference_mismatch'; }
     5643        return [
     5644            'status' => $errors ? 'candidate_references_require_review' : 'candidate_references_consistent',
     5645            'consistent' => !$errors,
     5646            'errors' => $errors,
     5647            'inventory_event_ids' => $inventory_ids,
     5648            'conflict_detail_event_ids' => array_map('intval', array_keys($detail_set)),
     5649            'missing_conflict_detail_event_ids' => $missing_details,
     5650            'unexpected_conflict_detail_event_ids' => $unexpected_details,
     5651            'duplicate_conflict_detail_event_ids' => array_values(array_unique($duplicate_detail_ids)),
     5652            'source_reference_mismatch_event_ids' => array_values(array_unique($source_mismatches)),
     5653            'identity_proven' => false,
     5654            'automatic_suppression_authorized' => false,
     5655            'policy' => 'Reference consistency validates audit traceability only; it does not establish shared event identity or authorize suppression.',
     5656        ];
     5657    }
     5658
     5659    /**
     5660     * Validate discovery-signal provenance for every retained candidate. Signals
     5661     * explain why a candidate was found; they are never treated as identity proof.
     5662     */
     5663    private function build_generic_candidate_discovery_signal_consistency($candidate_set_assessment, $conflict_assessment) {
     5664        $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : [];
     5665        $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : [];
     5666        $inventory_ids = array_values(array_unique(array_filter(array_map('absint', (array) ($candidate_set_assessment['candidate_event_ids'] ?? [])))));
     5667        $allowed_signals = ['fingerprint', 'temporal'];
     5668        $candidate_signals = [];
     5669        $missing_signal_ids = [];
     5670        $unknown_signals = [];
     5671        $duplicate_signals = [];
     5672        $unexpected_ids = [];
     5673        $inventory_set = array_fill_keys($inventory_ids, true);
     5674        foreach ((array) ($conflict_assessment['candidates'] ?? []) as $detail) {
     5675            if (!is_array($detail) || empty($detail['candidate_event_id'])) {
     5676                continue;
     5677            }
     5678            $event_id = absint($detail['candidate_event_id']);
     5679            if (!isset($inventory_set[$event_id])) {
     5680                $unexpected_ids[] = $event_id;
     5681            }
     5682            $signals = $detail['discovered_by'] ?? null;
     5683            if (!is_array($signals) || !$signals) {
     5684                $missing_signal_ids[] = $event_id;
     5685                $candidate_signals[$event_id] = [];
     5686                continue;
     5687            }
     5688            $normalized = [];
     5689            foreach ($signals as $signal) {
     5690                if (!is_string($signal) || !in_array($signal, $allowed_signals, true)) {
     5691                    $unknown_signals[] = ['candidate_event_id' => $event_id, 'signal' => is_scalar($signal) ? (string) $signal : gettype($signal)];
     5692                    continue;
     5693                }
     5694                if (in_array($signal, $normalized, true)) {
     5695                    $duplicate_signals[] = ['candidate_event_id' => $event_id, 'signal' => $signal];
     5696                }
     5697                $normalized[] = $signal;
     5698            }
     5699            $candidate_signals[$event_id] = array_values(array_unique($normalized));
     5700        }
     5701        $missing_candidate_ids = array_values(array_diff($inventory_ids, array_keys($candidate_signals)));
     5702        $errors = [];
     5703        if ($missing_signal_ids) { $errors[] = 'candidate_discovery_signal_missing'; }
     5704        if ($missing_candidate_ids) { $errors[] = 'candidate_discovery_record_missing'; }
     5705        if ($unexpected_ids) { $errors[] = 'discovery_signal_candidate_outside_inventory'; }
     5706        if ($unknown_signals) { $errors[] = 'unknown_candidate_discovery_signal'; }
     5707        if ($duplicate_signals) { $errors[] = 'duplicate_candidate_discovery_signal'; }
     5708        return [
     5709            'status' => $errors ? 'candidate_discovery_signals_require_review' : 'candidate_discovery_signals_consistent',
     5710            'consistent' => !$errors,
     5711            'errors' => $errors,
     5712            'allowed_signal_types' => $allowed_signals,
     5713            'candidate_signals_by_event_id' => $candidate_signals,
     5714            'missing_signal_event_ids' => array_values(array_unique($missing_signal_ids)),
     5715            'missing_candidate_record_event_ids' => $missing_candidate_ids,
     5716            'unexpected_candidate_event_ids' => array_values(array_unique($unexpected_ids)),
     5717            'unknown_signals' => $unknown_signals,
     5718            'duplicate_signals' => $duplicate_signals,
     5719            'identity_proven' => false,
     5720            'automatic_suppression_authorized' => false,
     5721            'policy' => 'Fingerprint and temporal signals describe discovery provenance only; neither signal establishes shared event identity or authorizes suppression.',
     5722        ];
     5723    }
     5724
     5725    /**
     5726     * Verify that adapter evidence preserves the discovery signals recorded for
     5727     * each candidate. Discovery provenance remains advisory and is never identity proof.
     5728     */
     5729    private function build_generic_adapter_discovery_evidence_consistency($conflict_assessment, $adapter_overlap_evidence) {
     5730        $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : [];
     5731        $adapter_overlap_evidence = is_array($adapter_overlap_evidence) ? $adapter_overlap_evidence : [];
     5732        $expected = [];
     5733        foreach ((array) ($conflict_assessment['candidates'] ?? []) as $candidate) {
     5734            if (!is_array($candidate) || empty($candidate['candidate_event_id'])) { continue; }
     5735            $id = absint($candidate['candidate_event_id']);
     5736            $expected[$id] = array_values(array_unique(array_filter((array) ($candidate['discovered_by'] ?? []), 'is_string')));
     5737        }
     5738        $seen = [];
     5739        $missing = [];
     5740        $unexpected = [];
     5741        $mismatched = [];
     5742        $duplicate = [];
     5743        $invalid = [];
     5744        $allowed = ['fingerprint', 'temporal'];
     5745        foreach ($adapter_overlap_evidence as $entry) {
     5746            if (!is_array($entry) || empty($entry['candidate_event_id'])) { $invalid[] = 'missing_candidate_event_id'; continue; }
     5747            $id = absint($entry['candidate_event_id']);
     5748            if (isset($seen[$id])) { $duplicate[] = $id; }
     5749            $seen[$id] = true;
     5750            if (!array_key_exists($id, $expected)) { $unexpected[] = $id; continue; }
     5751            $signals = $entry['discovered_by'] ?? null;
     5752            if (!is_array($signals)) { $invalid[] = $id; continue; }
     5753            foreach ($signals as $signal) {
     5754                if (!is_string($signal) || !in_array($signal, $allowed, true)) { $invalid[] = $id; }
     5755            }
     5756            $normalized = array_values(array_unique(array_filter($signals, 'is_string')));
     5757            sort($normalized);
     5758            $expected_normalized = $expected[$id];
     5759            sort($expected_normalized);
     5760            $comparison_signals = $entry['comparison']['identity_assessment']['candidate_discovery_signals'] ?? null;
     5761            if (!is_array($comparison_signals)) {
     5762                $mismatched[] = $id;
     5763            } else {
     5764                $comparison_normalized = array_values(array_unique(array_filter($comparison_signals, 'is_string')));
     5765                sort($comparison_normalized);
     5766                if ($comparison_normalized !== $normalized) { $mismatched[] = $id; }
     5767            }
     5768            if ($normalized !== $expected_normalized) { $mismatched[] = $id; }
     5769        }
     5770        $missing = array_values(array_diff(array_keys($expected), array_keys($seen)));
     5771        $errors = [];
     5772        if ($missing) { $errors[] = 'adapter_discovery_evidence_missing_candidates'; }
     5773        if ($unexpected) { $errors[] = 'adapter_discovery_evidence_has_unexpected_candidates'; }
     5774        if ($mismatched) { $errors[] = 'adapter_discovery_signals_disagree_with_candidate_inventory'; }
     5775        if ($duplicate) { $errors[] = 'adapter_discovery_evidence_has_duplicate_candidates'; }
     5776        if ($invalid) { $errors[] = 'adapter_discovery_evidence_has_invalid_signals_or_records'; }
     5777        return [
     5778            'status' => $errors ? 'adapter_discovery_evidence_requires_review' : 'adapter_discovery_evidence_consistent',
     5779            'consistent' => !$errors,
     5780            'errors' => $errors,
     5781            'candidate_count_expected' => count($expected),
     5782            'candidate_count_observed' => count($seen),
     5783            'missing_candidate_event_ids' => $missing,
     5784            'unexpected_candidate_event_ids' => array_values(array_unique($unexpected)),
     5785            'mismatched_candidate_event_ids' => array_values(array_unique($mismatched)),
     5786            'duplicate_candidate_event_ids' => array_values(array_unique($duplicate)),
     5787            'invalid_candidate_records' => array_values(array_unique($invalid, SORT_REGULAR)),
     5788            'identity_proven' => false,
     5789            'automatic_suppression_authorized' => false,
     5790            'policy' => 'Adapter evidence must preserve discovery provenance; matching signals remain discovery evidence only and never establish shared event identity.',
     5791        ];
     5792    }
     5793
     5794    /**
     5795     * Compare the normalized dimensions in adapter evidence with the dimensions
     5796     * independently classified for each candidate. This validates audit transport,
     5797     * not shared identity; missing values remain unknown rather than matching.
     5798     */
     5799    /**
     5800     * Classify temporal granularity without treating interval overlap as identity.
     5801     * Daily windows, aggregate periods and point timestamps are distinct evidence shapes.
     5802     */
     5803    /**
     5804     * Describe the temporal basis that can actually be established from the
     5805     * adapter contract. The current event table does not persist a timezone or
     5806     * timezone-version fingerprint, so even WordPress-local values produced by
     5807     * two adapters cannot safely be compared as absolute instants across imports.
     5808     * Calendar aggregates retain date-label semantics and are not converted to
     5809     * timestamps. This helper is descriptive and never authorizes suppression.
     5810     */
     5811    private function classify_reconciliation_temporal_basis($adapter_key, $origin_key, $record_type) {
     5812        $adapter_key = sanitize_key((string) $adapter_key);
     5813        $origin_key = sanitize_key((string) $origin_key);
     5814        $record_type = sanitize_key((string) $record_type);
     5815
     5816        if ($record_type === 'aggregate') {
     5817            return [
     5818                'basis' => 'aggregate_period_timezone_unverified',
     5819                'source_representation' => 'date_bounded_aggregate_or_period',
     5820                'absolute_time_comparable' => false,
     5821                'reason' => 'The adapter imports an aggregate period, but its exact calendar semantics, source timezone, and historical site-timezone setting are not persisted here.',
     5822            ];
     5823        }
     5824
     5825        if ($record_type === 'event' && $adapter_key === 'burst_statistics') {
     5826            return [
     5827                'basis' => 'wordpress_site_local_timezone_unrecorded',
     5828                'source_representation' => 'unix_timestamp_converted_with_wp_date',
     5829                'absolute_time_comparable' => false,
     5830                'reason' => 'The source instant is known, but the WordPress timezone identifier/offset at import time is not persisted with the event.',
     5831            ];
     5832        }
     5833
     5834        if ($record_type === 'event' && $adapter_key === 'independent_analytics') {
     5835            return [
     5836                'basis' => 'wordpress_site_local_timezone_unrecorded',
     5837                'source_representation' => 'utc_timestamp_converted_to_wp_local_with_wp_date',
     5838                'absolute_time_comparable' => false,
     5839                'reason' => 'The source is UTC and is converted to WordPress local time, but the conversion timezone identifier/offset is not persisted with the event.',
     5840            ];
     5841        }
     5842
     5843        if ($record_type === 'event' && $adapter_key === 'pulseviews' && $origin_key === 'recent_raw_views') {
     5844            return [
     5845                'basis' => 'source_local_timezone_unknown',
     5846                'source_representation' => 'naive_sql_datetime',
     5847                'absolute_time_comparable' => false,
     5848                'reason' => 'The raw source datetime has no documented timezone or UTC offset.',
     5849            ];
     5850        }
     5851
     5852        return [
     5853            'basis' => 'unknown',
     5854            'source_representation' => 'unverified',
     5855            'absolute_time_comparable' => false,
     5856            'reason' => 'The adapter does not provide sufficient persisted timezone provenance for absolute-time comparison.',
     5857        ];
     5858    }
     5859
     5860    private function classify_reconciliation_temporal_granularity($record_type, $period_start, $period_end, $precision_hint = 'unknown') {
     5861        if (!$this->is_valid_reconciliation_period_range($period_start, $period_end)) {
     5862            return [
     5863                'granularity' => 'unknown',
     5864                'classification' => 'insufficient_or_invalid_temporal_bounds',
     5865                'comparable_at_same_granularity' => false,
     5866            ];
     5867        }
     5868        if ($precision_hint === 'calendar_day'
     5869            || (substr($period_start, 11) === '00:00:00' && substr($period_end, 11) === '23:59:59')) {
     5870            return [
     5871                'granularity' => 'calendar_day',
     5872                'classification' => 'day_level_interval',
     5873                'comparable_at_same_granularity' => true,
     5874            ];
     5875        }
     5876        if ($record_type === 'aggregate') {
     5877            return [
     5878                'granularity' => 'aggregate_period',
     5879                'classification' => 'aggregate_interval',
     5880                'comparable_at_same_granularity' => true,
     5881            ];
     5882        }
     5883        if ($record_type === 'event' && $period_start === $period_end) {
     5884            return [
     5885                'granularity' => 'point_timestamp',
     5886                'classification' => 'event_timestamp_unverified',
     5887                'comparable_at_same_granularity' => true,
     5888            ];
     5889        }
     5890        return [
     5891            'granularity' => 'unknown',
     5892            'classification' => 'unrecognized_temporal_shape',
     5893            'comparable_at_same_granularity' => false,
     5894        ];
     5895    }
     5896
     5897    /**
     5898     * Accept only canonical local SQL datetime values for reconciliation periods.
     5899     * This validates representation, not timezone equivalence or event identity.
     5900     */
     5901    private function is_valid_reconciliation_period_datetime($value) {
     5902        if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $value)) {
     5903            return false;
     5904        }
     5905        $parsed = DateTime::createFromFormat('!Y-m-d H:i:s', $value);
     5906        $errors = DateTime::getLastErrors();
     5907        return $parsed instanceof DateTime
     5908            && $parsed->format('Y-m-d H:i:s') === $value
     5909            && ($errors === false || (empty($errors['warning_count']) && empty($errors['error_count'])));
     5910    }
     5911
     5912    /**
     5913     * Validate chronological ordering when both period boundaries are known.
     5914     * Canonical local SQL datetimes can be compared lexically; this does not
     5915     * infer timezone equivalence or prove event identity.
     5916     */
     5917    private function is_valid_reconciliation_period_range($period_start, $period_end) {
     5918        if (!$this->is_valid_reconciliation_period_datetime($period_start)
     5919            || !$this->is_valid_reconciliation_period_datetime($period_end)) {
     5920            return null;
     5921        }
     5922        return strcmp($period_end, $period_start) >= 0;
     5923    }
     5924
     5925    private function build_generic_adapter_dimension_evidence_consistency($conflict_assessment, $adapter_overlap_evidence) {
     5926        $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : [];
     5927        $adapter_overlap_evidence = is_array($adapter_overlap_evidence) ? $adapter_overlap_evidence : [];
     5928        $expected = [];
     5929        foreach ((array) ($conflict_assessment['candidates'] ?? []) as $candidate) {
     5930            if (!is_array($candidate) || empty($candidate['candidate_event_id'])) { continue; }
     5931            $expected[absint($candidate['candidate_event_id'])] = (array) ($candidate['dimension_checks'] ?? []);
     5932        }
     5933        $seen = [];
     5934        $missing = [];
     5935        $unexpected = [];
     5936        $mismatched = [];
     5937        $incomplete = [];
     5938        $invalid_period_ranges = [];
     5939        $dimensions = ['product_id', 'record_type', 'observed_views', 'period_start', 'period_end'];
     5940        foreach ($adapter_overlap_evidence as $entry) {
     5941            if (!is_array($entry) || empty($entry['candidate_event_id'])) { $incomplete[] = 'missing_candidate_event_id'; continue; }
     5942            $id = absint($entry['candidate_event_id']);
     5943            if (isset($seen[$id])) { $mismatched[] = $id; }
     5944            $seen[$id] = true;
     5945            if (!array_key_exists($id, $expected)) { $unexpected[] = $id; continue; }
     5946            $incoming = $entry['comparison']['incoming'] ?? null;
     5947            $candidate = $entry['comparison']['candidate'] ?? null;
     5948            if (!is_array($incoming) || !is_array($candidate)) { $incomplete[] = $id; continue; }
     5949            $actual = [];
     5950            foreach ($dimensions as $dimension) {
     5951                if (!array_key_exists($dimension, $incoming) || !array_key_exists($dimension, $candidate)) {
     5952                    $actual[$dimension] = null;
     5953                    continue;
     5954                }
     5955                if ($dimension === 'product_id' || $dimension === 'observed_views') {
     5956                    $left_value = $incoming[$dimension];
     5957                    $right_value = $candidate[$dimension];
     5958                    if ($left_value === null || $right_value === null || $left_value === '' || $right_value === ''
     5959                        || !is_numeric($left_value) || !is_numeric($right_value)) {
     5960                        $actual[$dimension] = null;
     5961                        $incomplete[] = $id . ':' . $dimension;
     5962                    } else {
     5963                        $left_number = (float) $left_value;
     5964                        $right_number = (float) $right_value;
     5965                        if ($left_number < 0 || $right_number < 0
     5966                            || ($dimension === 'product_id' && ($left_number <= 0 || $right_number <= 0))) {
     5967                            $actual[$dimension] = null;
     5968                            $incomplete[] = $id . ':' . $dimension;
     5969                        } else {
     5970                            $actual[$dimension] = $left_number === $right_number;
     5971                        }
     5972                    }
     5973                } elseif ($dimension === 'record_type') {
     5974                    $left = is_scalar($incoming[$dimension]) ? (string) $incoming[$dimension] : '';
     5975                    $right = is_scalar($candidate[$dimension]) ? (string) $candidate[$dimension] : '';
     5976                    $actual[$dimension] = ($left !== '' && $right !== '') ? $left === $right : null;
     5977                    if ($actual[$dimension] === null) { $incomplete[] = $id . ':' . $dimension; }
     5978                } else {
     5979                    $left = $incoming[$dimension];
     5980                    $right = $candidate[$dimension];
     5981                    if (!$this->is_valid_reconciliation_period_datetime($left)
     5982                        || !$this->is_valid_reconciliation_period_datetime($right)) {
     5983                        $actual[$dimension] = null;
     5984                        $incomplete[] = $id . ':' . $dimension;
     5985                    } else {
     5986                        $actual[$dimension] = $left === $right;
     5987                    }
     5988                }
     5989            }
     5990            foreach (['incoming' => $incoming, 'candidate' => $candidate] as $side => $period_data) {
     5991                $range_valid = $this->is_valid_reconciliation_period_range(
     5992                    $period_data['period_start'] ?? null,
     5993                    $period_data['period_end'] ?? null
     5994                );
     5995                if ($range_valid === false) {
     5996                    $invalid_period_ranges[] = $id . ':' . $side;
     5997                    $mismatched[] = $id;
     5998                } elseif ($range_valid === null) {
     5999                    // Boundary validity is already reported per dimension above.
     6000                    // Do not turn unknown boundaries into an ordering conclusion.
     6001                }
     6002            }
     6003            $expected_checks = $expected[$id];
     6004            foreach ($dimensions as $dimension) {
     6005                if (!array_key_exists($dimension, $expected_checks) || $expected_checks[$dimension] !== $actual[$dimension]) {
     6006                    $mismatched[] = $id;
     6007                    break;
     6008                }
     6009            }
     6010        }
     6011        $missing = array_values(array_diff(array_keys($expected), array_keys($seen)));
     6012        $errors = [];
     6013        if ($missing) { $errors[] = 'adapter_dimension_evidence_missing_candidates'; }
     6014        if ($unexpected) { $errors[] = 'adapter_dimension_evidence_has_unexpected_candidates'; }
     6015        if ($mismatched) { $errors[] = 'adapter_dimension_checks_disagree_with_conflict_assessment'; }
     6016        if ($incomplete) { $errors[] = 'adapter_dimension_evidence_incomplete'; }
     6017        if ($invalid_period_ranges) { $errors[] = 'adapter_period_range_end_precedes_start'; }
     6018        return [
     6019            'status' => $errors ? 'adapter_dimension_evidence_requires_review' : 'adapter_dimension_evidence_consistent',
     6020            'consistent' => !$errors,
     6021            'errors' => $errors,
     6022            'candidate_count_expected' => count($expected),
     6023            'candidate_count_observed' => count($seen),
     6024            'missing_candidate_event_ids' => $missing,
     6025            'unexpected_candidate_event_ids' => array_values(array_unique($unexpected)),
     6026            'mismatched_candidate_event_ids' => array_values(array_unique($mismatched)),
     6027            'incomplete_candidate_records' => array_values(array_unique($incomplete, SORT_REGULAR)),
     6028            'invalid_period_ranges' => array_values(array_unique($invalid_period_ranges)),
     6029            'identity_proven' => false,
     6030            'automatic_suppression_authorized' => false,
     6031            'policy' => 'Adapter dimension evidence must reproduce the independently recorded comparison checks. Missing dimensions remain unknown; reversed known period boundaries require review. Consistency validates audit integrity only and never proves shared event identity.',
     6032        ];
     6033    }
     6034
     6035    /**
     6036     * Validate the complete candidate reconciliation audit chain, including the
     6037     * independent candidate ordering checks. This is diagnostic only and cannot
     6038     * promote evidence to identity proof or change any applied contribution.
     6039     */
     6040    private function build_generic_candidate_reconciliation_chain_assessment($candidate_set_assessment, $conflict_assessment, $evidence_decision, $audit_consistency, $candidate_consistency, $reference_consistency = [], $discovery_signal_consistency = []) {
     6041        $candidate_set_assessment = is_array($candidate_set_assessment) ? $candidate_set_assessment : [];
     6042        $conflict_assessment = is_array($conflict_assessment) ? $conflict_assessment : [];
     6043        $evidence_decision = is_array($evidence_decision) ? $evidence_decision : [];
     6044        $audit_consistency = is_array($audit_consistency) ? $audit_consistency : [];
     6045        $candidate_consistency = is_array($candidate_consistency) ? $candidate_consistency : [];
     6046        $reference_consistency = is_array($reference_consistency) ? $reference_consistency : [];
     6047        $discovery_signal_consistency = is_array($discovery_signal_consistency) ? $discovery_signal_consistency : [];
     6048        $errors = [];
     6049
     6050        $inventory_count = max(0, (int) ($candidate_set_assessment['candidate_count_retained'] ?? 0));
     6051        $conflict_count = max(0, (int) ($conflict_assessment['candidate_count'] ?? 0));
     6052        $decision_count = max(0, (int) ($evidence_decision['candidate_count'] ?? 0));
     6053        $ordering_count = max(0, (int) ($candidate_consistency['candidate_count'] ?? 0));
     6054        $adapter_count = max(0, (int) ($candidate_consistency['adapter_evidence_candidate_count'] ?? 0));
     6055
     6056        if (count(array_unique([$inventory_count, $conflict_count, $decision_count, $ordering_count])) > 1) {
     6057            $errors[] = 'candidate_inventory_conflict_decision_and_ordering_counts_disagree';
     6058        }
     6059        if (!empty($candidate_consistency['adapter_evidence_order_consistent']) && $adapter_count !== $ordering_count) {
     6060            $errors[] = 'adapter_evidence_count_disagrees_despite_order_consistency_claim';
     6061        }
     6062        if (!empty($candidate_consistency['primary_in_candidate_list']) === false) {
     6063            $errors[] = 'primary_candidate_missing_from_candidate_list';
     6064        }
     6065        if (!empty($candidate_consistency['adapter_evidence_primary_consistent']) === false) {
     6066            $errors[] = 'adapter_evidence_primary_candidate_inconsistent';
     6067        }
     6068        if (!empty($candidate_consistency['adapter_evidence_order_consistent']) === false) {
     6069            $errors[] = 'adapter_evidence_candidate_order_inconsistent';
     6070        }
     6071        if (array_key_exists('adapter_discovery_evidence_consistent', $candidate_consistency)
     6072            && empty($candidate_consistency['adapter_discovery_evidence_consistent'])) {
     6073            $errors[] = 'adapter_discovery_evidence_provenance_inconsistent';
     6074        }
     6075        if (array_key_exists('adapter_dimension_evidence_consistent', $candidate_consistency)
     6076            && empty($candidate_consistency['adapter_dimension_evidence_consistent'])) {
     6077            $errors[] = 'adapter_dimension_evidence_inconsistent_with_conflict_assessment';
     6078        }
     6079        if (empty($audit_consistency['consistent'])) {
     6080            $errors[] = 'underlying_candidate_audit_layers_are_inconsistent';
     6081        }
     6082        if (array_key_exists('consistent', $reference_consistency) && empty($reference_consistency['consistent'])) {
     6083            $errors[] = 'candidate_event_or_source_references_are_inconsistent';
     6084        }
     6085        if (array_key_exists('consistent', $discovery_signal_consistency) && empty($discovery_signal_consistency['consistent'])) {
     6086            $errors[] = 'candidate_discovery_signal_provenance_is_inconsistent';
     6087        }
     6088        if (array_key_exists('classification_coverage_complete', $audit_consistency)
     6089            && empty($audit_consistency['classification_coverage_complete'])) {
     6090            $errors[] = 'candidate_classification_coverage_incomplete_in_chain';
     6091        }
     6092        if (!empty($candidate_set_assessment['candidate_set_truncated']) && empty($evidence_decision['candidate_search_truncated'])) {
     6093            $errors[] = 'truncation_missing_from_final_candidate_decision';
     6094        }
     6095        if (!empty($evidence_decision['identity_proven']) || !empty($candidate_consistency['identity_proven'])) {
     6096            $errors[] = 'candidate_reconciliation_chain_claims_identity_proof';
     6097        }
     6098        if (!empty($evidence_decision['automatic_suppression_authorized']) || !empty($candidate_consistency['automatic_suppression_authorized'])) {
     6099            $errors[] = 'candidate_reconciliation_chain_authorizes_automatic_suppression';
     6100        }
     6101
     6102        return [
     6103            'status' => $errors ? 'reconciliation_chain_requires_review' : 'reconciliation_chain_consistent',
     6104            'consistent' => !$errors,
     6105            'errors' => array_values(array_unique($errors)),
     6106            'candidate_counts' => [
     6107                'inventory' => $inventory_count,
     6108                'conflict_assessment' => $conflict_count,
     6109                'evidence_decision' => $decision_count,
     6110                'candidate_ordering' => $ordering_count,
     6111                'adapter_evidence' => $adapter_count,
     6112            ],
     6113            'candidate_search_truncated' => !empty($evidence_decision['candidate_search_truncated']),
     6114            'classification_coverage_complete' => !empty($audit_consistency['classification_coverage_complete']),
     6115            'classified_candidate_count' => max(0, (int) ($audit_consistency['classified_candidate_count'] ?? 0)),
     6116            'unclassified_candidate_count' => max(0, (int) ($audit_consistency['unclassified_candidate_count'] ?? 0)),
     6117            'candidate_reference_consistency' => $reference_consistency,
     6118            'candidate_discovery_signal_consistency' => $discovery_signal_consistency,
     6119            'adapter_discovery_evidence_consistent' => !empty($candidate_consistency['adapter_discovery_evidence_consistent']),
     6120            'adapter_dimension_evidence_consistent' => !empty($candidate_consistency['adapter_dimension_evidence_consistent']),
     6121            'identity_proven' => false,
     6122            'automatic_suppression_authorized' => false,
     6123            'policy' => 'This end-to-end check validates audit-chain consistency only. Any inconsistency requires review; no result changes applied_views, totals, or count suppression.',
     6124        ];
     6125    }
     6126
     6127    private function build_generic_product_reconciliation_summary($product_id, $cross_source, $partial_overlap, $product_synthesis, $matched_event = null, $candidate_set = null) {
     6128        $cross_source = is_array($cross_source) ? $cross_source : [];
     6129        $partial_overlap = is_array($partial_overlap) ? $partial_overlap : [];
     6130        $product_synthesis = is_array($product_synthesis) ? $product_synthesis : [];
     6131        $candidate_set = is_array($candidate_set) ? $candidate_set : [];
     6132        $candidate_set_assessment = $this->build_generic_candidate_set_assessment(
     6133            $candidate_set['candidates'] ?? [],
     6134            !empty($candidate_set['truncated']),
     6135            $candidate_set['observed_count'] ?? null
     6136        );
     6137
     6138        $capability_status = sanitize_key((string) ($product_synthesis['capability_profile_status'] ?? 'unknown'));
     6139        $synthesis_status = sanitize_key((string) ($product_synthesis['status'] ?? 'unknown'));
     6140        $partial_status = sanitize_key((string) ($partial_overlap['status'] ?? 'unknown'));
     6141        $cross_truncated = !empty($cross_source['candidate_set_truncated']);
     6142        $inventory_truncated = !empty($product_synthesis['source_record_groups_truncated']);
     6143        $partial_truncated = !empty($partial_overlap['records_truncated']);
     6144
     6145        $limitations = [];
     6146        if ($synthesis_status !== 'available') { $limitations[] = 'product_source_synthesis_' . ($synthesis_status ?: 'unknown'); }
     6147        if (!in_array($capability_status, ['available'], true)) { $limitations[] = 'source_capability_profiles_' . ($capability_status ?: 'unknown'); }
     6148        if ($cross_truncated) { $limitations[] = 'cross_source_candidate_set_truncated'; }
     6149        if ($inventory_truncated) { $limitations[] = 'product_source_record_groups_truncated'; }
     6150        if ($partial_status !== 'ok') { $limitations[] = 'partial_overlap_' . ($partial_status ?: 'unknown'); }
     6151        if ($partial_truncated) { $limitations[] = 'partial_overlap_records_truncated'; }
     6152
     6153        $source_count = max(0, (int) ($product_synthesis['source_count_in_scope'] ?? 0));
     6154        $capability_count = max(0, (int) ($product_synthesis['capability_profile_source_count'] ?? 0));
     6155        $complete = !$limitations && $source_count === $capability_count;
     6156
     6157        // Classify the available evidence without making a new counting decision.
     6158        // The exact-match flag is supplied only by the importer's existing path,
     6159        // which has already validated the shared fingerprint and normalized dimensions.
     6160        $candidate_records = max(0, (int) ($cross_source['candidate_record_count'] ?? 0));
     6161        $candidate_sources = max(0, (int) ($cross_source['candidate_source_count'] ?? 0));
     6162        if (is_array($matched_event) && !empty($matched_event['id'])) {
     6163            $decision_classification = 'confirmed_exact_shared_fingerprint_match';
     6164            $decision_basis = 'existing_importer_exact_match_contract';
     6165            $decision_identity_proven = true;
     6166        } elseif (!empty($candidate_set_assessment['multiple_candidate_ambiguity'])) {
     6167            $decision_classification = 'multiple_cross_source_candidates_require_review';
     6168            $decision_basis = 'all_candidates_retained_without_identity_resolution';
     6169            $decision_identity_proven = false;
     6170        } elseif ($candidate_records > 0 || $candidate_sources > 0) {
     6171            $decision_classification = 'possible_cross_source_overlap_requires_review';
     6172            $decision_basis = 'candidate_fingerprint_or_temporal_evidence_only';
     6173            $decision_identity_proven = false;
     6174        } else {
     6175            $decision_classification = 'insufficient_evidence_to_establish_cross_source_overlap';
     6176            $decision_basis = 'no_candidate_found_within_declared_search_bounds';
     6177            $decision_identity_proven = false;
     6178        }
     6179
     6180        return [
     6181            'version' => '1.0.0',
     6182            'status' => $complete ? 'evidence_inventory_complete_within_declared_bounds' : 'evidence_inventory_partial_or_limited',
     6183            'product_id' => absint($product_id),
     6184            'source_scope' => 'all_persisted_generic_sources_for_product',
     6185            'source_count' => $source_count,
     6186            'capability_profile_source_count' => $capability_count,
     6187            'cross_source_candidate_count' => max(0, (int) ($cross_source['candidate_source_count'] ?? 0)),
     6188            'partial_overlap_source_count' => count((array) ($partial_overlap['profiles'] ?? [])),
     6189            'same_bucket_count_bounds' => $cross_source['identical_bucket_count_range'] ?? ['status' => 'unavailable'],
     6190            'reconciliation_decision' => [
     6191                'classification' => $decision_classification,
     6192                'basis' => $decision_basis,
     6193                'identity_proven' => $decision_identity_proven,
     6194                'summary_authorizes_count_change' => false,
     6195                'candidate_search_truncated' => $cross_truncated,
     6196                'candidate_set_assessment' => $candidate_set_assessment,
     6197                'policy' => 'This classification describes evidence only. It does not independently authorize suppression, adjustment, or rewriting of applied_views.',
     6198            ],
     6199            'unique_view_total' => null,
     6200            'counting_decision' => 'preserve_all_applied_contributions_no_automatic_suppression',
     6201            'limitations' => array_values(array_unique($limitations)),
     6202            'interpretation' => 'This summary combines source inventory, declared capabilities, candidate overlap evidence, and the availability limits of partial-period comparison. It does not prove event identity or calculate a deduplicated unique-view total.',
     6203        ];
     6204    }
     6205
     6206    /**
     6207     * Partial aggregate-period duration comparisons are withheld until source
     6208     * timezone provenance is persisted. Naive datetime strings cannot safely be
     6209     * converted into shared elapsed-time intervals.
     6210     */
     6211    private function build_generic_partial_overlap_profiles($product_id, $source_id, $period_start, $period_end) {
     6212        // Persisted period strings do not include source timezone provenance.
     6213        // Do not report elapsed seconds or coverage ratios from naive datetimes.
     6214        return [
     6215            'status' => 'unavailable',
     6216            'reason' => 'timezone_basis_not_persisted',
     6217            'incoming_period_seconds' => null,
     6218            'profiles' => [],
     6219            'records_truncated' => false,
     6220            'identity_confidence' => 'unproven_without_shared_event_identity',
     6221            'temporal_comparison' => 'not_performed',
     6222        ];
     6223    }
     6224
     6225    /** Execute the importer while the caller holds the source-origin advisory lock. */
     6226    private function process_generic_historical_view_import_batch($source_id, $origin_key, $retry_checkpoint_override = null, $origin_lock = null) {
     6227        global $wpdb;
     6228
     6229        $source_id = absint($source_id);
     6230        $origin_key = sanitize_key((string) $origin_key);
     6231        if (!$source_id || $origin_key === '') {
     6232            return ['status' => 'invalid_input'];
     6233        }
     6234        if (!$this->generic_advisory_lock_session_is_current($origin_lock)) {
     6235            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6236            return ['status' => 'origin_lock_session_lost'];
     6237        }
     6238
     6239        $state = $this->get_view_source_origin_state($source_id, $origin_key);
     6240        if (empty($state['status']) || $state['status'] !== 'found') {
     6241            return ['status' => 'origin_not_found'];
     6242        }
     6243        // A queued action may outlive a provider's latest discovery inventory.
     6244        // Never let such a stale action process an origin that has since been
     6245        // marked unavailable or is no longer explicitly detected as available.
     6246        if ($state['origin_status'] === 'unavailable' || $state['detection_status'] !== 'available') {
     6247            return ['status' => 'origin_unavailable'];
     6248        }
     6249        if ($state['origin_status'] === 'completed' && $retry_checkpoint_override === null) {
     6250            return ['status' => 'already_completed'];
     6251        }
     6252        // A terminal failure for one record must not stop the main source scan.
     6253        // The failed record remains in the ledger for review, while later records
     6254        // continue importing. Only the independent retry worker is capped at three
     6255        // total attempts for that individual record.
     6256        if (!is_array($state['snapshot']) || empty($state['snapshot']['boundary']) || !is_array($state['checkpoint'])) {
     6257            return ['status' => 'snapshot_not_ready'];
     6258        }
     6259
     6260        $adapter_key = isset($state['adapter_key']) ? sanitize_key($state['adapter_key']) : '';
     6261        $adapter = $this->get_historical_view_adapter($adapter_key);
     6262        if (!$adapter) {
     6263            // A scheduled request may have arrived before provider registration.
     6264            $this->discover_historical_view_adapters();
     6265            $adapter = $this->get_historical_view_adapter($adapter_key);
     6266        }
     6267        if (!$adapter) {
     6268            // Provider plugins may be temporarily inactive, load later than this
     6269            // worker, or briefly fail while assembling their registry. Keep the
     6270            // immutable snapshot/checkpoint untouched and retry discovery later.
     6271            // Normal plugin initialization also rediscovers providers, so a
     6272            // returning adapter can resume automatically without administrator
     6273            // intervention. A replacement adapter still cannot reinterpret the
     6274            // persisted identity because register_view_source_origin() guards it.
     6275            $this->update_view_source_origin_state($source_id, $origin_key, [
     6276                'last_error' => 'The registered historical adapter is temporarily unavailable; automatic discovery will be retried.',
     6277            ], $origin_lock);
     6278            $this->schedule_generic_historical_view_import_batch(21600, $source_id, $origin_key);
     6279            return ['status' => 'adapter_unavailable', 'retry_scheduled' => true];
     6280        }
     6281
     6282        $checkpoint = ($retry_checkpoint_override !== null && is_array($retry_checkpoint_override))
     6283            ? $retry_checkpoint_override
     6284            : $state['checkpoint'];
     6285        $batch_start_checkpoint = $checkpoint;
     6286        try {
     6287            $batch = $adapter->get_product_view_batch($origin_key, $checkpoint, $state['snapshot'], 100);
     6288        } catch (Throwable $exception) {
     6289            $failure = $this->record_generic_historical_view_failure($source_id,
     6290                    $origin_key,
     6291                    'batch:' . substr(hash('sha256', wp_json_encode($checkpoint)), 0, 32),
     6292                    0,
     6293                    $exception->getMessage(),
     6294                    null,
     6295                    8,
     6296                    $origin_lock);
     6297            if (!$failure['blocked']) {
     6298                $this->schedule_generic_historical_view_import_batch($failure['retry_delay'], $source_id, $origin_key);
     6299            }
     6300            return ['status' => $failure['blocked'] ? 'needs_attention' : 'source_read_failed', 'attempts' => $failure['attempts']];
     6301        }
     6302
     6303        $batch_rows_are_valid_list = is_array($batch)
     6304            && isset($batch['rows'])
     6305            && is_array($batch['rows'])
     6306            && array_keys($batch['rows']) === (count($batch['rows']) > 0 ? range(0, count($batch['rows']) - 1) : [])
     6307            && count($batch['rows']) <= 100;
     6308        if ($batch_rows_are_valid_list) {
     6309            foreach ($batch['rows'] as $batch_row) {
     6310                if (!is_array($batch_row)) {
     6311                    $batch_rows_are_valid_list = false;
     6312                    break;
     6313                }
     6314            }
     6315        }
     6316
     6317        $batch_is_valid = is_array($batch)
     6318            && $batch_rows_are_valid_list
     6319            && array_key_exists('has_more', $batch)
     6320            && is_bool($batch['has_more'])
     6321            // The adapter must apply the immutable snapshot boundary in its source query.
     6322            && isset($batch['boundary_applied'])
     6323            && $batch['boundary_applied'] === true;
     6324
     6325        if ($batch_is_valid && $batch['has_more']) {
     6326            $batch_is_valid = !empty($batch['rows'])
     6327                && array_key_exists('next_checkpoint', $batch)
     6328                && (is_array($batch['next_checkpoint']) || is_scalar($batch['next_checkpoint']))
     6329                && $this->is_valid_generic_historical_cursor($batch['next_checkpoint'])
     6330                && wp_json_encode($batch['next_checkpoint']) !== wp_json_encode($checkpoint);
     6331        }
     6332
     6333        if (!$batch_is_valid) {
     6334            $failure = $this->record_generic_historical_view_failure($source_id,
     6335                    $origin_key,
     6336                    'batch:' . substr(hash('sha256', wp_json_encode($checkpoint)), 0, 32),
     6337                    0,
     6338                    'Adapter returned an invalid or non-progressing batch. Expected a sequential list of at most 100 array rows, a boolean has_more, boundary_applied=true, and an advancing next_checkpoint whenever more rows remain.',
     6339                    null,
     6340                    8,
     6341                    $origin_lock);
     6342            if (!$failure['blocked']) {
     6343                $this->schedule_generic_historical_view_import_batch($failure['retry_delay'], $source_id, $origin_key);
     6344            }
     6345            return ['status' => $failure['blocked'] ? 'needs_attention' : 'invalid_batch', 'attempts' => $failure['attempts']];
     6346        }
     6347
     6348        // The source has now returned a structurally valid, snapshot-bounded batch;
     6349        // clear a previous transient batch-read failure before processing its records.
     6350        $batch_failure_cleared = $this->clear_generic_historical_view_failure(
     6351            $source_id,
     6352            $origin_key,
     6353            'batch:' . substr(hash('sha256', wp_json_encode($checkpoint)), 0, 32)
     6354        , $origin_lock);
     6355        if (!$batch_failure_cleared) {
     6356            // Do not consume this batch while its previous batch-level failure
     6357            // remains unresolved in the durable ledger.
     6358            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6359            return ['status' => 'failure_ledger_clear_failed'];
     6360        }
     6361
     6362        $processed = 0;
     6363        $duplicates = 0;
     6364        $failed = 0;
     6365        $skipped = 0;
     6366        $next_checkpoint = $checkpoint;
     6367
     6368        $checkpoint_persistence_failed = false;
     6369        foreach ($batch['rows'] as $record) {
     6370            try {
     6371                $normalized = $adapter->normalize_record($origin_key, $record);
     6372            } catch (Throwable $exception) {
     6373                $normalized = null;
     6374                $normalization_error = $exception->getMessage();
     6375            }
     6376
     6377            // A replay of a saved batch must retry only records whose own retry
     6378            // deadline has arrived. Otherwise one due record could cause blocked
     6379            // or not-yet-due neighbours in the same batch to be retried indefinitely.
     6380            if ($retry_checkpoint_override !== null) {
     6381                if (is_array($normalized)) {
     6382                    $retry_record_key = !empty($normalized['source_record_key'])
     6383                        ? (string) $normalized['source_record_key']
     6384                        : wp_json_encode($record);
     6385                } elseif (is_array($record) && isset($record['source_record_key'])) {
     6386                    $retry_record_key = (string) $record['source_record_key'];
     6387                } else {
     6388                    $retry_record_key = wp_json_encode($record);
     6389                }
     6390                $retry_record_key = substr((string) $retry_record_key, 0, 191);
     6391                $retry_record_hash = hash('sha256', (string) $origin_key . "\0" . $retry_record_key);
     6392                $retry_is_due = statpl_db_get_var(statpl_db_prepare(
     6393                    "SELECT id FROM {$this->view_source_failures_table}
     6394                     WHERE source_id = %d AND source_record_hash = %s
     6395                       AND status = 'retry' AND next_retry_at <= %s
     6396                     LIMIT 1",
     6397                    $source_id,
     6398                    $retry_record_hash,
     6399                    current_time('mysql')
     6400                ));
     6401                if (!empty($wpdb->last_error)) {
     6402                    $this->schedule_generic_historical_failure_retry(300, $source_id, $origin_key);
     6403                    return ['status' => 'retry_ledger_lookup_failed', 'processed' => $processed, 'duplicates' => $duplicates];
     6404                }
     6405                if (!$retry_is_due) {
     6406                    continue;
     6407                }
     6408            }
     6409
     6410            if (is_array($normalized) && !empty($normalized['skip'])) {
     6411                // Adapters may explicitly identify a source row as outside their
     6412                // product-view scope (for example, a site-wide analytics row for
     6413                // a blog page). Such rows are not failures and must not block the
     6414                // scan, but their source key and cursor still require validation.
     6415                $skip_record_key = isset($normalized['source_record_key'])
     6416                    ? trim((string) $normalized['source_record_key']) : '';
     6417                $skip_cursor = array_key_exists('cursor', $normalized) ? $normalized['cursor'] : null;
     6418                if ($skip_record_key === '' || strlen($skip_record_key) > 191
     6419                    || !$this->is_valid_generic_historical_cursor($skip_cursor)) {
     6420                    $normalized = null;
     6421                    $normalization_error = 'Adapter returned an invalid explicit skip record.';
     6422                } else {
     6423                    $skip_failure_cleared = $this->clear_generic_historical_view_failure(
     6424                        $source_id,
     6425                        $origin_key,
     6426                        $skip_record_key
     6427                    , $origin_lock, null);
     6428                    if (!$skip_failure_cleared) {
     6429                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6430                        $checkpoint_persistence_failed = true;
     6431                        break;
     6432                    }
     6433                    $next_checkpoint['cursor'] = $skip_cursor;
     6434                    $skipped++;
     6435                    continue;
     6436                }
     6437            }
     6438
     6439            if (!is_array($normalized)) {
     6440                $failed++;
     6441                $failure = $this->record_generic_historical_view_failure(
     6442                    $source_id,
     6443                    $origin_key,
     6444                    (is_array($record) && isset($record['source_record_key'])) ? $record['source_record_key'] : wp_json_encode($record),
     6445                    0,
     6446                    isset($normalization_error) ? $normalization_error : 'Adapter could not normalize a source record.',
     6447                    $batch_start_checkpoint,
     6448                    3
     6449                , $origin_lock);
     6450                if (empty($failure['persisted'])) {
     6451                    // Without a durable failure-ledger entry, do not advance past this record.
     6452                    $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6453                    $checkpoint_persistence_failed = true;
     6454                    break;
     6455                }
     6456                if (!$failure['blocked']) {
     6457                    $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6458                }
     6459                unset($normalization_error);
     6460                continue;
     6461            }
     6462
     6463            $product_id = isset($normalized['product_id']) ? absint($normalized['product_id']) : 0;
     6464            $record_key = isset($normalized['source_record_key']) ? trim((string) $normalized['source_record_key']) : '';
     6465            $raw_views = array_key_exists('views', $normalized) ? $normalized['views'] : null;
     6466            $views_is_valid = is_int($raw_views) && $raw_views >= 0;
     6467            if (is_string($raw_views) && preg_match('/^(0|[1-9]\d*)$/D', $raw_views)) {
     6468                // Do not compare large decimal strings through float: on 64-bit PHP,
     6469                // (float) PHP_INT_MAX rounds to 2^63 and can admit an overflowing value.
     6470                $max_views_string = (string) PHP_INT_MAX;
     6471                $raw_views_length = strlen($raw_views);
     6472                $max_views_length = strlen($max_views_string);
     6473                $views_is_valid = $raw_views_length < $max_views_length
     6474                    || ($raw_views_length === $max_views_length && strcmp($raw_views, $max_views_string) <= 0);
     6475            }
     6476            $views = $views_is_valid ? (int) $raw_views : -1;
     6477            $cursor = array_key_exists('cursor', $normalized) ? $normalized['cursor'] : null;
     6478            $record_type = isset($normalized['record_type']) ? sanitize_key($normalized['record_type']) : 'event';
     6479            $period_start = array_key_exists('period_start', $normalized) && $normalized['period_start'] !== null
     6480                ? (string) $normalized['period_start'] : null;
     6481            $period_end = array_key_exists('period_end', $normalized) && $normalized['period_end'] !== null
     6482                ? (string) $normalized['period_end'] : null;
     6483            $periods_are_valid = $this->is_valid_generic_historical_datetime($period_start)
     6484                && $this->is_valid_generic_historical_datetime($period_end);
     6485            if ($periods_are_valid && $period_start !== null && $period_end !== null) {
     6486                $periods_are_valid = strcmp($period_start, $period_end) <= 0;
     6487            }
     6488
     6489            if (
     6490                !$product_id || get_post_type($product_id) !== 'product'
     6491                || $record_key === '' || strlen($record_key) > 191
     6492                || !$views_is_valid || $views < 0 || !in_array($record_type, ['event', 'aggregate'], true)
     6493                || !$this->is_valid_generic_historical_cursor($cursor)
     6494                || !$periods_are_valid
     6495                || ($record_type === 'aggregate' && ($period_start === null || $period_end === null))
     6496            ) {
     6497                $failed++;
     6498                $failure = $this->record_generic_historical_view_failure(
     6499                    $source_id,
     6500                    $origin_key,
     6501                    $record_key !== '' ? $record_key : wp_json_encode($record),
     6502                    $product_id,
     6503                    'Normalized record failed validation (product, key, views, type or cursor).',
     6504                    $batch_start_checkpoint,
     6505                    3
     6506                , $origin_lock);
     6507                if (empty($failure['persisted'])) {
     6508                    $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6509                    $checkpoint_persistence_failed = true;
     6510                    break;
     6511                }
     6512                if (!$failure['blocked']) {
     6513                    $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6514                }
     6515                unset($normalization_error);
     6516                continue;
     6517            }
     6518
     6519            $record_hash = hash('sha256', $origin_key . "\0" . $record_key);
     6520            $now = current_time('mysql');
     6521            $source_fingerprint = isset($normalized['source_fingerprint'])
     6522                ? strtolower(trim((string) $normalized['source_fingerprint']))
     6523                : '';
     6524            if ($source_fingerprint !== '' && !preg_match('/^[a-f0-9]{64}$/D', $source_fingerprint)) {
     6525                $failed++;
     6526                $failure = $this->record_generic_historical_view_failure(
     6527                    $source_id,
     6528                    $origin_key,
     6529                    $record_key,
     6530                    $product_id,
     6531                    'Adapter supplied an invalid cross-source fingerprint; expected exactly 64 hexadecimal characters.',
     6532                    $batch_start_checkpoint,
     6533                    3
     6534                , $origin_lock);
     6535                if (empty($failure['persisted'])) {
     6536                    // Without a durable failure-ledger entry, do not advance past this record.
     6537                    $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6538                    $checkpoint_persistence_failed = true;
     6539                    break;
     6540                }
     6541                if (!$failure['blocked']) {
     6542                    $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6543                }
     6544                continue;
     6545            }
     6546
     6547            // Cross-plugin reconciliation is deliberately conservative: suppress a
     6548            // second contribution only when both adapters provide the same explicit
     6549            // stable fingerprint AND all normalized event dimensions agree exactly.
     6550            // A fingerprint-scoped advisory lock also serializes the no-existing-row
     6551            // case, which SELECT ... FOR UPDATE alone cannot reliably protect.
     6552            $reconciliation_lock = 'statpl_xr_' . substr(hash('sha256', $wpdb->prefix . ':' . $source_fingerprint), 0, 48);
     6553            $reconciliation_lock_state = null;
     6554            // Aggregate fingerprints derived from product/date/count are not shared
     6555            // event identities. They must never drive exact cross-source suppression.
     6556            // Keep exact fingerprint reconciliation restricted to individual events.
     6557            $has_exact_event_fingerprint = $source_fingerprint !== '' && $record_type === 'event';
     6558            if ($has_exact_event_fingerprint) {
     6559                $reconciliation_lock_state = $this->acquire_generic_advisory_lock($reconciliation_lock);
     6560                if (empty($reconciliation_lock_state['acquired'])) {
     6561                    $this->schedule_generic_historical_view_import_batch(30, $source_id, $origin_key);
     6562                    return ['status' => 'cross_source_lock_busy', 'processed' => $processed, 'duplicates' => $duplicates];
     6563                }
     6564                if (!$this->generic_advisory_lock_session_is_current($reconciliation_lock_state)) {
     6565                    $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6566                    return ['status' => 'cross_source_lock_session_lost', 'processed' => $processed, 'duplicates' => $duplicates];
     6567                }
     6568            }
     6569
     6570            $transaction_open = false;
     6571            try {
     6572                if (statpl_db_query('START TRANSACTION') === false) {
     6573                    $failed++;
     6574                    $transaction_error = (string) $wpdb->last_error;
     6575                    $failure = $this->record_generic_historical_view_failure(
     6576                        $source_id,
     6577                        $origin_key,
     6578                        $record_key,
     6579                        $product_id,
     6580                        'Could not start atomic source-event transaction: ' . $transaction_error,
     6581                        $batch_start_checkpoint,
     6582                        3
     6583                    , $origin_lock);
     6584                    if (empty($failure['persisted'])) {
     6585                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6586                        $checkpoint_persistence_failed = true;
     6587                    break;
     6588                    }
     6589                    if (!$failure['blocked']) {
     6590                        $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6591                    }
     6592                    continue;
     6593                }
     6594                $transaction_open = true;
     6595                $matched_event = null;
     6596            $possible_overlap_event = null;
     6597            $possible_overlap_events = [];
     6598            $possible_overlap_candidates_truncated = false;
     6599            $possible_overlap_fingerprint_candidates_observed = 0;
     6600            $possible_overlap_temporal_candidates_observed = 0;
     6601            $possible_overlap_candidate_count_observed = 0;
     6602            $possible_overlap_candidate_count_is_lower_bound = false;
     6603            $possible_overlap_reason_code = '';
     6604            if ($has_exact_event_fingerprint) {
     6605                $matched_event = statpl_db_get_row(statpl_db_prepare(
     6606                    "SELECT id, source_id, product_id, record_type, observed_views, period_start, period_end, applied_views
     6607                     FROM {$this->view_source_events_table}
     6608                     WHERE source_fingerprint = %s
     6609                       AND source_id <> %d
     6610                       AND product_id = %d
     6611                       AND record_type = %s
     6612                       AND observed_views = %d
     6613                       AND ((period_start IS NULL AND %s = '') OR period_start = %s)
     6614                       AND ((period_end IS NULL AND %s = '') OR period_end = %s)
     6615                     ORDER BY id ASC LIMIT 1 FOR UPDATE",
     6616                    $source_fingerprint,
     6617                    $source_id,
     6618                    $product_id,
     6619                    $record_type,
     6620                    $views,
     6621                    (string) $period_start,
     6622                    $period_start,
     6623                    (string) $period_end,
     6624                    $period_end
     6625                ), ARRAY_A);
     6626                if (!empty($wpdb->last_error)) {
     6627                    $lookup_error = $wpdb->last_error;
     6628                    if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     6629                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6630                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     6631                    }
     6632                    $failed++;
     6633                    $failure = $this->record_generic_historical_view_failure(
     6634                        $source_id,
     6635                        $origin_key,
     6636                        $record_key,
     6637                        $product_id,
     6638                        'Cross-source reconciliation lookup failed: ' . $lookup_error,
     6639                        $batch_start_checkpoint,
     6640                        3
     6641                    , $origin_lock);
     6642                    if (empty($failure['persisted'])) {
     6643                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6644                        $checkpoint_persistence_failed = true;
     6645                    break;
     6646                    }
     6647                    if (!$failure['blocked']) {
     6648                        $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6649                    }
     6650                    continue;
     6651                }
     6652                if (!$matched_event) {
     6653                    // Same explicit fingerprint with different normalized dimensions is
     6654                    // evidence of a possible/partial overlap, not proof of a duplicate.
     6655                    // Preserve and count this contribution, but retain an auditable
     6656                    // candidate link so a source-specific reconciliation rule can review it.
     6657                    $fingerprint_candidate_result = $this->get_generic_reconciliation_candidates_by_source(
     6658                        'fingerprint',
     6659                        ['fingerprint' => $source_fingerprint, 'source_id' => $source_id],
     6660                        25
     6661                    );
     6662                    $possible_overlap_events = $fingerprint_candidate_result['events'];
     6663                    $possible_overlap_fingerprint_candidates_observed = (int) $fingerprint_candidate_result['observed'];
     6664                    if ($fingerprint_candidate_result['status'] === 'error') {
     6665                        $lookup_error = $fingerprint_candidate_result['error'];
     6666                        if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     6667                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6668                            return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     6669                        }
     6670                        $failed++;
     6671                        $failure = $this->record_generic_historical_view_failure(
     6672                            $source_id, $origin_key, $record_key, $product_id,
     6673                            'Possible-overlap reconciliation lookup failed: ' . $lookup_error,
     6674                            $batch_start_checkpoint, 3, $origin_lock
     6675                        );
     6676                        if (empty($failure['persisted'])) {
     6677                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6678                            $checkpoint_persistence_failed = true;
     6679                            break;
     6680                        }
     6681                        if (!$failure['blocked']) {
     6682                            $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6683                        }
     6684                        continue;
     6685                    }
     6686                    if (!empty($fingerprint_candidate_result['truncated'])) {
     6687                        $possible_overlap_candidates_truncated = true;
     6688                        $possible_overlap_candidate_count_is_lower_bound = true;
     6689                    }
     6690                    if (!empty($possible_overlap_events)) {
     6691                        $possible_overlap_event = $possible_overlap_events[0];
     6692                        $possible_overlap_reason_code = 'shared_fingerprint_dimension_mismatch';
     6693                    }
     6694                    if (!empty($wpdb->last_error)) {
     6695                        $lookup_error = $wpdb->last_error;
     6696                        if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     6697                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6698                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     6699                    }
     6700                        $failed++;
     6701                        $failure = $this->record_generic_historical_view_failure(
     6702                            $source_id,
     6703                            $origin_key,
     6704                            $record_key,
     6705                            $product_id,
     6706                            'Possible-overlap reconciliation lookup failed: ' . $lookup_error,
     6707                            $batch_start_checkpoint,
     6708                            3
     6709                        , $origin_lock);
     6710                        if (empty($failure['persisted'])) {
     6711                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6712                            $checkpoint_persistence_failed = true;
     6713                    break;
     6714                        }
     6715                        if (!$failure['blocked']) {
     6716                            $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6717                        }
     6718                        continue;
     6719                    }
     6720                }
     6721            }
     6722            // Cross-source temporal overlap is retained as evidence only. This
     6723            // includes aggregate/event, aggregate/aggregate, and event/event pairs.
     6724            // Event timestamps can reveal strong candidate relationships even when
     6725            // providers do not share an event ID; temporal proximity alone still does
     6726            // not prove identity, so this discovery step never suppresses counts.
     6727            if (
     6728                !$matched_event
     6729                && in_array($record_type, ['aggregate', 'event'], true)
     6730                && $period_start !== null
     6731                && $period_end !== null
     6732            ) {
     6733                $temporal_candidate_result = $this->get_generic_reconciliation_candidates_by_source(
     6734                    'temporal',
     6735                    ['source_id' => $source_id, 'product_id' => $product_id, 'period_start' => $period_start, 'period_end' => $period_end],
     6736                    25
     6737                );
     6738                $temporal_overlap_events = $temporal_candidate_result['events'];
     6739                if ($temporal_candidate_result['status'] === 'error') {
     6740                    $lookup_error = $temporal_candidate_result['error'];
     6741                    if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     6742                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6743                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     6744                    }
     6745                    $failed++;
     6746                    $failure = $this->record_generic_historical_view_failure(
     6747                        $source_id, $origin_key, $record_key, $product_id,
     6748                        'Cross-source temporal-overlap lookup failed: ' . $lookup_error,
     6749                        $batch_start_checkpoint, 3, $origin_lock
     6750                    );
     6751                    if (empty($failure['persisted'])) {
     6752                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6753                        $checkpoint_persistence_failed = true;
     6754                        break;
     6755                    }
     6756                    if (!$failure['blocked']) {
     6757                        $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6758                    }
     6759                    continue;
     6760                }
     6761                if (!empty($wpdb->last_error)) {
     6762                    $lookup_error = $wpdb->last_error;
     6763                    if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     6764                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6765                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     6766                    }
     6767                    $failed++;
     6768                    $failure = $this->record_generic_historical_view_failure(
     6769                        $source_id,
     6770                        $origin_key,
     6771                        $record_key,
     6772                        $product_id,
     6773                        'Cross-source temporal-overlap lookup failed: ' . $lookup_error,
     6774                        $batch_start_checkpoint,
     6775                        3
     6776                    , $origin_lock);
     6777                    if (empty($failure['persisted'])) {
     6778                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     6779                        $checkpoint_persistence_failed = true;
     6780                        break;
     6781                    }
     6782                    if (!$failure['blocked']) {
     6783                        $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     6784                    }
     6785                    continue;
     6786                }
     6787                $possible_overlap_temporal_candidates_observed = (int) $temporal_candidate_result['observed'];
     6788                if (!empty($temporal_candidate_result['truncated'])) {
     6789                    $possible_overlap_candidates_truncated = true;
     6790                    $possible_overlap_candidate_count_is_lower_bound = true;
     6791                }
     6792
     6793                $candidate_discovery_by_id = [];
     6794                foreach ($possible_overlap_events as $candidate_event) {
     6795                    $candidate_id = isset($candidate_event['id']) ? (int) $candidate_event['id'] : 0;
     6796                    if ($candidate_id > 0) {
     6797                        $candidate_discovery_by_id[$candidate_id]['fingerprint'] = true;
     6798                    }
     6799                }
     6800                foreach ($temporal_overlap_events as $candidate_event) {
     6801                    $candidate_id = isset($candidate_event['id']) ? (int) $candidate_event['id'] : 0;
     6802                    if ($candidate_id > 0) {
     6803                        $candidate_discovery_by_id[$candidate_id]['temporal'] = true;
     6804                    }
     6805                }
     6806
     6807                $candidate_events_by_id = [];
     6808                foreach (array_merge($possible_overlap_events, $temporal_overlap_events) as $candidate_event) {
     6809                    $candidate_id = isset($candidate_event['id']) ? (int) $candidate_event['id'] : 0;
     6810                    if ($candidate_id > 0) {
     6811                        $candidate_event['_discovered_by'] = array_keys($candidate_discovery_by_id[$candidate_id] ?? []);
     6812                        $candidate_events_by_id[$candidate_id] = $candidate_event;
     6813                    }
     6814                }
     6815                $possible_overlap_events = array_values($candidate_events_by_id);
     6816                usort($possible_overlap_events, static function ($left, $right) {
     6817                    $left_sources = isset($left['_discovered_by']) ? $left['_discovered_by'] : [];
     6818                    $right_sources = isset($right['_discovered_by']) ? $right['_discovered_by'] : [];
     6819                    $left_rank = count($left_sources) > 1 ? 0 : (in_array('fingerprint', $left_sources, true) ? 1 : 2);
     6820                    $right_rank = count($right_sources) > 1 ? 0 : (in_array('fingerprint', $right_sources, true) ? 1 : 2);
     6821                    if ($left_rank !== $right_rank) {
     6822                        return $left_rank <=> $right_rank;
     6823                    }
     6824                    return (int) $left['id'] <=> (int) $right['id'];
     6825                });
     6826                $possible_overlap_candidate_count_observed = count($possible_overlap_events);
     6827                if (count($possible_overlap_events) > 25) {
     6828                    $possible_overlap_candidates_truncated = true;
     6829                    $possible_overlap_candidate_count_is_lower_bound = true;
     6830                    $possible_overlap_events = array_slice($possible_overlap_events, 0, 25);
     6831                }
     6832                if (!empty($possible_overlap_events)) {
     6833                    $possible_overlap_event = $possible_overlap_events[0];
     6834                }
     6835                if ($possible_overlap_event) {
     6836                    $has_fingerprint_candidate = false;
     6837                    $has_cross_type_candidate = false;
     6838                    $has_same_type_event_candidate = false;
     6839                    foreach ($possible_overlap_events as $candidate_event) {
     6840                        $discovery = isset($candidate_event['_discovered_by']) ? $candidate_event['_discovered_by'] : [];
     6841                        if (in_array('fingerprint', $discovery, true)) {
     6842                            $has_fingerprint_candidate = true;
     6843                        }
     6844                        if (
     6845                            in_array('temporal', $discovery, true)
     6846                            && isset($candidate_event['record_type'])
     6847                            && $candidate_event['record_type'] !== $record_type
     6848                        ) {
     6849                            $has_cross_type_candidate = true;
     6850                        }
     6851                        if (
     6852                            in_array('temporal', $discovery, true)
     6853                            && $record_type === 'event'
     6854                            && isset($candidate_event['record_type'])
     6855                            && $candidate_event['record_type'] === 'event'
     6856                        ) {
     6857                            $has_same_type_event_candidate = true;
     6858                        }
     6859                    }
     6860                    if ($has_fingerprint_candidate) {
     6861                        $possible_overlap_reason_code = 'shared_fingerprint_dimension_mismatch';
     6862                    } elseif ($has_cross_type_candidate) {
     6863                        $possible_overlap_reason_code = 'aggregate_event_temporal_overlap';
     6864                    } elseif ($has_same_type_event_candidate) {
     6865                        $possible_overlap_reason_code = 'event_timestamp_overlap';
     6866                    } elseif ($possible_overlap_reason_code === '') {
     6867                        $possible_overlap_reason_code = 'overlapping_aggregate_periods';
     6868                    }
     6869                }
     6870            }
     6871
     6872            if (empty($temporal_overlap_events) && !empty($possible_overlap_events)) {
     6873                foreach ($possible_overlap_events as &$candidate_event) {
     6874                    $candidate_event['_discovered_by'] = ['fingerprint'];
     6875                }
     6876                unset($candidate_event);
     6877                $possible_overlap_candidate_count_observed = count($possible_overlap_events);
     6878                if (!empty($possible_overlap_events)) {
     6879                    $possible_overlap_event = $possible_overlap_events[0];
     6880                }
     6881            }
     6882
     6883            // Optional source-specific analysis enriches the audit trail for partial
     6884            // overlaps. It is deliberately advisory: an adapter cannot silently change
     6885            // the counting policy or suppress a contribution through this extension.
     6886            $adapter_overlap_evidence = [];
     6887            $adapter_evidence_bytes = 0;
     6888            $adapter_evidence_total_limit = 65536;
     6889            $adapter_evidence_candidate_limit = 4096;
     6890            $adapter_evidence_omitted = 0;
     6891            if (!$matched_event && $possible_overlap_event && method_exists($adapter, 'get_reconciliation_evidence')) {
     6892                $overlap_candidates = !empty($possible_overlap_events)
     6893                    ? $possible_overlap_events
     6894                    : [$possible_overlap_event];
     6895                foreach ($overlap_candidates as $candidate_event) {
     6896                    try {
     6897                        $adapter_evidence = $adapter->get_reconciliation_evidence(
     6898                            $origin_key,
     6899                            $normalized,
     6900                            $candidate_event
     6901                        );
     6902                    } catch (Throwable $exception) {
     6903                        $adapter_evidence = [
     6904                            'analysis_status' => 'failed',
     6905                            'error' => substr(sanitize_text_field($exception->getMessage()), 0, 500),
     6906                        ];
     6907                    }
     6908
     6909                    if (!is_array($adapter_evidence)) {
     6910                        $adapter_evidence = ['analysis_status' => 'invalid_result'];
     6911                    }
     6912                    $encoded_adapter_evidence = wp_json_encode($adapter_evidence);
     6913                    $candidate_evidence_size = is_string($encoded_adapter_evidence) ? strlen($encoded_adapter_evidence) : 0;
     6914                    if (!is_string($encoded_adapter_evidence)) {
     6915                        $adapter_evidence = [
     6916                            'analysis_status' => 'rejected',
     6917                            'reason' => 'Evidence was not JSON serializable.',
     6918                        ];
     6919                        $candidate_evidence_size = strlen(wp_json_encode($adapter_evidence));
     6920                    } elseif ($candidate_evidence_size > $adapter_evidence_candidate_limit) {
     6921                        $adapter_evidence = [
     6922                            'analysis_status' => 'rejected',
     6923                            'reason' => 'Evidence exceeded the 4 KiB per-candidate limit.',
     6924                            'original_bytes' => $candidate_evidence_size,
     6925                        ];
     6926                        $candidate_evidence_size = strlen(wp_json_encode($adapter_evidence));
     6927                    }
     6928                    if ($adapter_evidence_bytes + $candidate_evidence_size > $adapter_evidence_total_limit) {
     6929                        $adapter_evidence_omitted++;
     6930                        $adapter_evidence = [
     6931                            'analysis_status' => 'omitted_budget',
     6932                            'reason' => 'The 64 KiB total adapter-evidence budget was reached.',
     6933                        ];
     6934                        $candidate_evidence_size = strlen(wp_json_encode($adapter_evidence));
     6935                    }
     6936                    $adapter_evidence_bytes += $candidate_evidence_size;
     6937                    $candidate_start = isset($candidate_event['period_start']) ? (string) $candidate_event['period_start'] : '';
     6938                    $candidate_end = isset($candidate_event['period_end']) ? (string) $candidate_event['period_end'] : '';
     6939                    $incoming_start = (string) $period_start;
     6940                    $incoming_end = (string) $period_end;
     6941                    $incoming_has_period = $incoming_start !== '' && $incoming_end !== '';
     6942                    $candidate_has_period = $candidate_start !== '' && $candidate_end !== '';
     6943                    // Exact string equality remains a representation-level signal;
     6944                    // it is not a timezone-safe temporal comparison.
     6945                    $same_start = $incoming_has_period && $candidate_has_period && $incoming_start === $candidate_start;
     6946                    $same_end = $incoming_has_period && $candidate_has_period && $incoming_end === $candidate_end;
     6947                    $incoming_temporal_basis = $this->classify_reconciliation_temporal_basis(
     6948                        $adapter_key,
     6949                        $origin_key,
     6950                        $record_type
     6951                    );
     6952                    $candidate_adapter_key = isset($candidate_event['candidate_adapter_key'])
     6953                        ? sanitize_key((string) $candidate_event['candidate_adapter_key']) : '';
     6954                    $candidate_record_key = isset($candidate_event['source_record_key'])
     6955                        ? (string) $candidate_event['source_record_key'] : '';
     6956                    $candidate_origin_key = '';
     6957                    if ($candidate_adapter_key === 'pulseviews') {
     6958                        if (strpos($candidate_record_key, 'pulseviews_day_') === 0) {
     6959                            $candidate_origin_key = 'daily_aggregates';
     6960                        } elseif (strpos($candidate_record_key, 'pulseviews_view_') === 0) {
     6961                            $candidate_origin_key = 'recent_raw_views';
     6962                        }
     6963                    }
     6964                    $candidate_temporal_basis = $this->classify_reconciliation_temporal_basis(
     6965                        $candidate_adapter_key,
     6966                        $candidate_origin_key,
     6967                        isset($candidate_event['record_type']) ? $candidate_event['record_type'] : ''
     6968                    );
     6969                    $periods_intersect = false;
     6970                    $temporal_relation = ($incoming_has_period && $candidate_has_period)
     6971                        ? 'not_compared_timezone_basis_unverified'
     6972                        : 'insufficient_temporal_data';
     6973                    $incoming_precision = 'unknown';
     6974                    if ($record_type === 'aggregate') {
     6975                        $incoming_precision = 'aggregate_period';
     6976                    } elseif ($adapter_key === 'statify') {
     6977                        $incoming_precision = 'calendar_day';
     6978                    } elseif (in_array($adapter_key, ['burst_statistics', 'independent_analytics'], true)) {
     6979                        $incoming_precision = 'seconds';
     6980                    } elseif ($adapter_key === 'pulseviews') {
     6981                        $incoming_precision = $origin_key === 'daily_aggregates' ? 'calendar_day' : 'seconds';
     6982                    } elseif ($record_type === 'event' && $incoming_start !== '' && $incoming_start === $incoming_end) {
     6983                        $incoming_precision = 'source_event_timestamp_unverified';
     6984                    }
     6985                    $candidate_precision = (isset($candidate_event['record_type']) && $candidate_event['record_type'] === 'aggregate')
     6986                        ? 'aggregate_period'
     6987                        : ((isset($candidate_event['record_type']) && $candidate_event['record_type'] === 'event' && $candidate_start !== '' && $candidate_start === $candidate_end)
     6988                            ? 'stored_event_timestamp_unverified'
     6989                            : 'unknown');
     6990                    $incoming_granularity = $this->classify_reconciliation_temporal_granularity(
     6991                        (string) $record_type,
     6992                        $incoming_start,
     6993                        $incoming_end,
     6994                        $incoming_precision
     6995                    );
     6996                    $candidate_granularity = $this->classify_reconciliation_temporal_granularity(
     6997                        isset($candidate_event['record_type']) ? (string) $candidate_event['record_type'] : '',
     6998                        $candidate_start,
     6999                        $candidate_end,
     7000                        $candidate_precision
     7001                    );
     7002                    $same_temporal_granularity = $incoming_granularity['granularity'] !== 'unknown'
     7003                        && $incoming_granularity['granularity'] === $candidate_granularity['granularity'];
     7004                    $temporal_granularity_relation = $same_temporal_granularity
     7005                        ? 'same_granularity_temporal_evidence_only'
     7006                        : (($incoming_granularity['granularity'] !== 'unknown' && $candidate_granularity['granularity'] !== 'unknown')
     7007                            ? 'different_temporal_granularity'
     7008                            : 'insufficient_temporal_granularity');
     7009
     7010                    $candidate_rank = count($adapter_overlap_evidence) + 1;
     7011                    $adapter_overlap_evidence[] = [
     7012                        'candidate_event_id' => (int) $candidate_event['id'],
     7013                        'candidate_rank' => $candidate_rank,
     7014                        'is_primary_candidate' => $candidate_rank === 1
     7015                            && (int) $candidate_event['id'] === (int) $possible_overlap_event['id'],
     7016                        'discovered_by' => isset($candidate_event['_discovered_by']) ? $candidate_event['_discovered_by'] : [],
     7017                        'adapter_key' => $adapter_key,
     7018                        'comparison' => [
     7019                            'identity_assessment' => [
     7020                                'candidate_discovery_signals' => isset($candidate_event['_discovered_by'])
     7021                                    ? array_values((array) $candidate_event['_discovered_by'])
     7022                                    : [],
     7023                                'same_explicit_fingerprint_signal' => in_array(
     7024                                    'fingerprint',
     7025                                    (array) ($candidate_event['_discovered_by'] ?? []),
     7026                                    true
     7027                                ) && $source_fingerprint !== '',
     7028                                'classification' => (
     7029                                    in_array('fingerprint', (array) ($candidate_event['_discovered_by'] ?? []), true)
     7030                                    && $source_fingerprint !== ''
     7031                                ) ? 'shared_fingerprint_candidate_requires_contract_and_dimension_validation' : 'temporal_or_other_candidate_without_shared_identity',
     7032                                'identity_proven' => false,
     7033                                'automatic_suppression_authorized' => false,
     7034                                'reason' => 'Candidate discovery by a matching fingerprint is a review signal only. Identity is confirmed only by the separate exact-match path, which also requires event type and all normalized dimensions to agree.',
     7035                            ],
     7036                            'incoming' => [
     7037                                'source_id' => (int) $source_id,
     7038                                'origin_key' => (string) $origin_key,
     7039                                'product_id' => (int) $product_id,
     7040                                'record_type' => (string) $record_type,
     7041                                'observed_views' => (int) $views,
     7042                                'period_start' => $incoming_start,
     7043                                'period_end' => $incoming_end,
     7044                                'temporal_basis' => $incoming_temporal_basis,
     7045                            ],
     7046                            'candidate' => [
     7047                                'source_id' => isset($candidate_event['source_id']) ? (int) $candidate_event['source_id'] : 0,
     7048                                'source_key' => isset($candidate_event['candidate_source_key']) ? (string) $candidate_event['candidate_source_key'] : null,
     7049                                'adapter_key' => isset($candidate_event['candidate_adapter_key']) ? (string) $candidate_event['candidate_adapter_key'] : null,
     7050                                'product_id' => isset($candidate_event['product_id']) ? (int) $candidate_event['product_id'] : 0,
     7051                                'record_type' => isset($candidate_event['record_type']) ? (string) $candidate_event['record_type'] : '',
     7052                                'observed_views' => isset($candidate_event['observed_views']) ? (int) $candidate_event['observed_views'] : 0,
     7053                                'applied_views' => isset($candidate_event['applied_views']) ? (int) $candidate_event['applied_views'] : 0,
     7054                                'period_start' => $candidate_start,
     7055                                'period_end' => $candidate_end,
     7056                                'temporal_basis' => $candidate_temporal_basis,
     7057                            ],
     7058                            'temporal_analysis' => [
     7059                                'incoming_precision' => $incoming_precision,
     7060                                'candidate_precision' => $candidate_precision,
     7061                                'relation' => $temporal_relation,
     7062                                'incoming_basis' => $incoming_temporal_basis,
     7063                                'candidate_basis' => $candidate_temporal_basis,
     7064                                'absolute_time_comparison_performed' => false,
     7065                                'incoming_granularity' => $incoming_granularity,
     7066                                'candidate_granularity' => $candidate_granularity,
     7067                                'granularity_relation' => $temporal_granularity_relation,
     7068                                'same_granularity_is_identity_proof' => false,
     7069                                'same_granularity_establishes_timezone_compatibility' => false,
     7070                                'same_period_bounds' => $same_start && $same_end,
     7071                                'same_instant_bounds' => false,
     7072                                'same_event_datetime_string_representation' => $same_start && $same_end
     7073                                    && $record_type === 'event'
     7074                                    && isset($candidate_event['record_type']) && $candidate_event['record_type'] === 'event',
     7075                                'same_instant_is_proven' => false,
     7076                                'interpretation' => 'Temporal similarity is evidence of possible overlap only; it does not establish event identity.',
     7077                            ],
     7078                            'signals' => [
     7079                                'same_product' => isset($candidate_event['product_id']) && (int) $candidate_event['product_id'] === (int) $product_id,
     7080                                'same_record_type' => isset($candidate_event['record_type']) && (string) $candidate_event['record_type'] === (string) $record_type,
     7081                                'same_period_start' => $candidate_start !== '' && $candidate_start === $incoming_start,
     7082                                'same_period_end' => $candidate_end !== '' && $candidate_end === $incoming_end,
     7083                                'same_observed_view_count' => isset($candidate_event['observed_views']) && (int) $candidate_event['observed_views'] === (int) $views,
     7084                                'same_explicit_fingerprint_candidate_signal' => in_array('fingerprint', (array) ($candidate_event['_discovered_by'] ?? []), true) && $source_fingerprint !== '',
     7085                                'periods_intersect' => null,
     7086                                'same_period_string_representation' => $same_start && $same_end,
     7087                                'period_string_equality_is_temporal_proof' => false,
     7088                            ],
     7089                        ],
     7090                        'evidence_contract_version' => '1.7.0',
     7091                        'evidence' => $adapter_evidence,
     7092                    ];
     7093                }
     7094            }
     7095
     7096            if ($adapter_evidence_omitted > 0 && !empty($adapter_overlap_evidence)) {
     7097                $adapter_overlap_evidence[0]['evidence_budget_summary'] = [
     7098                    'total_limit_bytes' => $adapter_evidence_total_limit,
     7099                    'candidate_limit_bytes' => $adapter_evidence_candidate_limit,
     7100                    'omitted_candidates' => $adapter_evidence_omitted,
     7101                    'stored_evidence_bytes' => $adapter_evidence_bytes,
     7102                ];
     7103            }
     7104
     7105            $applied_views = $matched_event ? 0 : $views;
     7106            $inserted = statpl_db_insert($this->view_source_events_table, [
     7107                'source_id' => $source_id,
     7108                'source_record_key' => $record_key,
     7109                'source_record_hash' => $record_hash,
     7110                'product_id' => $product_id,
     7111                'record_type' => $record_type,
     7112                'period_start' => $period_start,
     7113                'period_end' => $period_end,
     7114                'observed_views' => $views,
     7115                'applied_views' => $applied_views,
     7116                'source_fingerprint' => $has_exact_event_fingerprint ? $source_fingerprint : null,
     7117                'status' => $matched_event ? 'reconciled' : ($possible_overlap_event ? 'possible_overlap' : 'applied'),
     7118                'created_at' => $now,
     7119                'updated_at' => $now,
     7120            ]);
     7121
     7122            if ($inserted === 1) {
     7123                $new_event_id = (int) $wpdb->insert_id;
     7124                // Build the shared audit context for both exact matches and possible
     7125                // overlaps. The possible-overlap persistence path below also stores
     7126                // these values, so they must never depend on the exact-match branch.
     7127                $cross_source_assessment = null;
     7128                $partial_overlap_profiles = null;
     7129                $product_source_synthesis = null;
     7130                $product_reconciliation_summary = null;
     7131                if ($matched_event || $possible_overlap_event) {
     7132                    $audit_candidates = !empty($possible_overlap_events)
     7133                        ? $possible_overlap_events
     7134                        : ($matched_event ? [$matched_event] : [$possible_overlap_event]);
     7135                    $cross_source_assessment = $this->build_generic_cross_source_overlap_assessment(
     7136                        $product_id,
     7137                        $source_id,
     7138                        $adapter_key,
     7139                        $record_type,
     7140                        $period_start,
     7141                        $period_end,
     7142                        $audit_candidates,
     7143                        $possible_overlap_candidates_truncated,
     7144                        $views
     7145                    );
     7146                    $partial_overlap_profiles = $this->build_generic_partial_overlap_profiles(
     7147                        $product_id,
     7148                        $source_id,
     7149                        $period_start,
     7150                        $period_end
     7151                    );
     7152                    $product_source_synthesis = $this->build_generic_product_source_synthesis($product_id);
     7153                    $product_reconciliation_summary = $this->build_generic_product_reconciliation_summary(
     7154                        $product_id,
     7155                        $cross_source_assessment,
     7156                        $partial_overlap_profiles,
     7157                        $product_source_synthesis,
     7158                        $matched_event,
     7159                        [
     7160                            'candidates' => !empty($possible_overlap_events)
     7161                                ? $possible_overlap_events
     7162                                : ($matched_event ? [$matched_event] : ($possible_overlap_event ? [$possible_overlap_event] : [])),
     7163                            'truncated' => $possible_overlap_candidates_truncated,
     7164                            'observed_count' => $possible_overlap_candidate_count_observed,
     7165                        ]
     7166                    );
     7167                }
     7168                if ($matched_event) {
     7169                    $adapter_discovery_evidence_consistency = $this->build_generic_adapter_discovery_evidence_consistency(
     7170                        $candidate_conflict_assessment,
     7171                        $adapter_overlap_evidence
     7172                    );
     7173
     7174                    $evidence = wp_json_encode([
     7175                        'match_type' => 'exact_shared_fingerprint',
     7176                        'source_id' => $source_id,
     7177                        'matched_source_id' => (int) $matched_event['source_id'],
     7178                        'product_id' => $product_id,
     7179                        'record_type' => $record_type,
     7180                        'observed_views' => $views,
     7181                        'period_start' => $period_start,
     7182                        'period_end' => $period_end,
     7183                        'source_fingerprint' => $source_fingerprint,
     7184                        'cross_source_assessment' => $cross_source_assessment,
     7185                        'partial_overlap_profiles' => $partial_overlap_profiles,
     7186                        'product_source_synthesis' => $product_source_synthesis,
     7187                        'product_reconciliation_summary' => $product_reconciliation_summary,
     7188                        'candidate_set_assessment' => $this->build_generic_candidate_set_assessment(
     7189                            !empty($possible_overlap_events) ? $possible_overlap_events : [$matched_event],
     7190                            $possible_overlap_candidates_truncated,
     7191                            $possible_overlap_candidate_count_observed
     7192                        ),
     7193                    ]);
     7194                    $reconciliation_inserted = statpl_db_insert($this->view_source_reconciliation_table, [
     7195                        'event_id' => $new_event_id,
     7196                        'status' => 'matched_exact',
     7197                        'reason_code' => 'shared_fingerprint_exact_match',
     7198                        'reason' => 'A different source supplied the same explicit fingerprint and identical normalized event dimensions. This contribution was retained for audit but not added again.',
     7199                        'matched_event_id' => (int) $matched_event['id'],
     7200                        'evidence' => $evidence,
     7201                        'algorithm_version' => '1.0.0',
     7202                        'created_at' => $now,
     7203                        'updated_at' => $now,
     7204                    ]);
     7205                    $history_inserted = $reconciliation_inserted === 1 ? statpl_db_insert($this->view_source_reconciliation_history_table, [
     7206                        'event_id' => $new_event_id,
     7207                        'previous_status' => null,
     7208                        'new_status' => 'matched_exact',
     7209                        'reason_code' => 'shared_fingerprint_exact_match',
     7210                        'reason' => 'Exact cross-source match using an adapter-provided shared fingerprint and identical normalized dimensions.',
     7211                        'matched_event_id' => (int) $matched_event['id'],
     7212                        'evidence' => $evidence,
     7213                        'algorithm_version' => '1.0.0',
     7214                        'created_at' => $now,
     7215                    ]) : false;
     7216                    if ($reconciliation_inserted !== 1 || $history_inserted !== 1) {
     7217                        if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     7218                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7219                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7220                    }
     7221                        $failed++;
     7222                        $failure = $this->record_generic_historical_view_failure(
     7223                            $source_id,
     7224                            $origin_key,
     7225                            $record_key,
     7226                            $product_id,
     7227                            'Could not persist cross-source reconciliation evidence: ' . $wpdb->last_error,
     7228                            $batch_start_checkpoint,
     7229                            3
     7230                        , $origin_lock);
     7231                        if (empty($failure['persisted'])) {
     7232                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7233                            $checkpoint_persistence_failed = true;
     7234                    break;
     7235                        }
     7236                        if (!$failure['blocked']) {
     7237                            $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     7238                        }
     7239                        continue;
     7240                    }
     7241                }
     7242                if ($possible_overlap_event) {
     7243                    $adapter_dimension_evidence_consistency = $this->build_generic_adapter_dimension_evidence_consistency(
     7244                        $candidate_conflict_assessment,
     7245                        $adapter_overlap_evidence
     7246                    );
     7247                    // Validate that the primary reference, ordered candidate set and
     7248                    // adapter evidence describe the same candidate identities before
     7249                    // persisting the reconciliation record.
     7250                    $primary_candidate_id = (int) $possible_overlap_event['id'];
     7251                    $candidate_ids = array_values(array_unique(array_map(
     7252                        static function ($candidate) {
     7253                            return isset($candidate['id']) ? (int) $candidate['id'] : 0;
     7254                        },
     7255                        !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event]
     7256                    )));
     7257                    $candidate_ids = array_values(array_filter($candidate_ids, static function ($id) {
     7258                        return $id > 0;
     7259                    }));
     7260                    $candidate_list_primary_consistent = in_array($primary_candidate_id, $candidate_ids, true)
     7261                        && !empty($candidate_ids)
     7262                        && $candidate_ids[0] === $primary_candidate_id;
     7263
     7264                    $adapter_candidate_ids = array_values(array_map(
     7265                        static function ($entry) {
     7266                            return isset($entry['candidate_event_id']) ? (int) $entry['candidate_event_id'] : 0;
     7267                        },
     7268                        $adapter_overlap_evidence
     7269                    ));
     7270                    $adapter_evidence_primary_consistent = empty($adapter_overlap_evidence)
     7271                        || (
     7272                            isset($adapter_overlap_evidence[0]['is_primary_candidate'])
     7273                            && $adapter_overlap_evidence[0]['is_primary_candidate'] === true
     7274                            && $adapter_candidate_ids[0] === $primary_candidate_id
     7275                        );
     7276                    $adapter_evidence_order_consistent = true;
     7277                    foreach ($adapter_overlap_evidence as $index => $entry) {
     7278                        if (
     7279                            !isset($entry['candidate_rank'], $entry['candidate_event_id'])
     7280                            || (int) $entry['candidate_rank'] !== $index + 1
     7281                            || (int) $entry['candidate_event_id'] !== (int) ($candidate_ids[$index] ?? 0)
     7282                        ) {
     7283                            $adapter_evidence_order_consistent = false;
     7284                            break;
     7285                        }
     7286                    }
     7287
     7288                    $evidence = wp_json_encode([
     7289                        'match_type' => $possible_overlap_reason_code,
     7290                        'source_id' => $source_id,
     7291                        'matched_source_id' => (int) $possible_overlap_event['source_id'],
     7292                        'matched_event_id' => (int) $possible_overlap_event['id'],
     7293                        'current' => [
     7294                            'product_id' => $product_id,
     7295                            'record_type' => $record_type,
     7296                            'observed_views' => $views,
     7297                            'period_start' => $period_start,
     7298                            'period_end' => $period_end,
     7299                        ],
     7300                        'candidates_truncated' => $possible_overlap_candidates_truncated,
     7301                        'candidate_limit' => 25,
     7302                        'candidate_count_observed' => $possible_overlap_candidate_count_observed,
     7303                        'candidate_count_is_lower_bound' => $possible_overlap_candidate_count_is_lower_bound,
     7304                        'candidate_set_assessment' => $this->build_generic_candidate_set_assessment(
     7305                            !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event],
     7306                            $possible_overlap_candidates_truncated,
     7307                            $possible_overlap_candidate_count_observed
     7308                        ),
     7309                        'candidate_conflict_assessment' => $candidate_conflict_assessment = $this->build_generic_candidate_conflict_assessment(
     7310                            [
     7311                                'product_id' => $product_id,
     7312                                'record_type' => $record_type,
     7313                                'observed_views' => $views,
     7314                                'period_start' => $period_start,
     7315                                'period_end' => $period_end,
     7316                            ],
     7317                            !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event]
     7318                        ),
     7319                        'candidate_evidence_decision' => $candidate_evidence_decision = $this->build_generic_candidate_evidence_decision(
     7320                            $candidate_conflict_assessment,
     7321                            $candidate_set_assessment = $this->build_generic_candidate_set_assessment(
     7322                                !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event],
     7323                                $possible_overlap_candidates_truncated,
     7324                                $possible_overlap_candidate_count_observed
     7325                            ),
     7326                            $possible_overlap_candidates_truncated
     7327                        ),
     7328                        'candidate_audit_consistency' => $this->build_generic_candidate_audit_consistency(
     7329                            $candidate_set_assessment,
     7330                            $candidate_conflict_assessment,
     7331                            $candidate_evidence_decision
     7332                        ),
     7333                        'candidate_reference_consistency' => $this->build_generic_candidate_reference_consistency(
     7334                            $candidate_set_assessment,
     7335                            $candidate_conflict_assessment
     7336                        ),
     7337                        'candidate_discovery_signal_consistency' => $this->build_generic_candidate_discovery_signal_consistency(
     7338                            $candidate_set_assessment,
     7339                            $candidate_conflict_assessment
     7340                        ),
     7341                        'adapter_discovery_evidence_consistency' => $adapter_discovery_evidence_consistency,
     7342                        'adapter_dimension_evidence_consistency' => $adapter_dimension_evidence_consistency,
     7343                        'candidate_consistency' => [
     7344                            'primary_in_candidate_list' => $candidate_list_primary_consistent,
     7345                            'adapter_evidence_primary_consistent' => $adapter_evidence_primary_consistent,
     7346                            'adapter_evidence_order_consistent' => $adapter_evidence_order_consistent,
     7347                            'adapter_discovery_evidence_consistent' => !empty($adapter_discovery_evidence_consistency['consistent']),
     7348                            'adapter_dimension_evidence_consistent' => !empty($adapter_dimension_evidence_consistency['consistent']),
     7349                            'candidate_count' => count($candidate_ids),
     7350                            'adapter_evidence_candidate_count' => count($adapter_candidate_ids),
     7351                            'validation_policy' => 'audit_only_no_automatic_count_suppression',
     7352                        ],
     7353                        'candidate_reconciliation_chain' => $this->build_generic_candidate_reconciliation_chain_assessment(
     7354                            $candidate_set_assessment,
     7355                            $candidate_conflict_assessment,
     7356                            $candidate_evidence_decision,
     7357                            $this->build_generic_candidate_audit_consistency(
     7358                                $candidate_set_assessment,
     7359                                $candidate_conflict_assessment,
     7360                                $candidate_evidence_decision
     7361                            ),
     7362                            [
     7363                                'primary_in_candidate_list' => $candidate_list_primary_consistent,
     7364                                'adapter_evidence_primary_consistent' => $adapter_evidence_primary_consistent,
     7365                                'adapter_evidence_order_consistent' => $adapter_evidence_order_consistent,
     7366                                'adapter_discovery_evidence_consistent' => !empty($adapter_discovery_evidence_consistency['consistent']),
     7367                                'adapter_dimension_evidence_consistent' => !empty($adapter_dimension_evidence_consistency['consistent']),
     7368                                'candidate_count' => count($candidate_ids),
     7369                                'adapter_evidence_candidate_count' => count($adapter_candidate_ids),
     7370                            ],
     7371                            $this->build_generic_candidate_reference_consistency(
     7372                                $candidate_set_assessment,
     7373                                $candidate_conflict_assessment
     7374                            ),
     7375                            $this->build_generic_candidate_discovery_signal_consistency(
     7376                                $candidate_set_assessment,
     7377                                $candidate_conflict_assessment
     7378                            )
     7379                        ),
     7380                        'primary_candidate' => [
     7381                            'event_id' => (int) $possible_overlap_event['id'],
     7382                            'source_id' => (int) $possible_overlap_event['source_id'],
     7383                            'source_key' => isset($possible_overlap_event['candidate_source_key']) ? (string) $possible_overlap_event['candidate_source_key'] : null,
     7384                            'adapter_key' => isset($possible_overlap_event['candidate_adapter_key']) ? (string) $possible_overlap_event['candidate_adapter_key'] : null,
     7385                            'discovered_by' => isset($possible_overlap_event['_discovered_by']) ? $possible_overlap_event['_discovered_by'] : [],
     7386                            'selection_policy' => 'highest_discovery_evidence_then_lowest_event_id',
     7387                        ],
     7388                        'fingerprint_candidates_observed' => $possible_overlap_fingerprint_candidates_observed,
     7389                        'temporal_candidates_observed' => $possible_overlap_temporal_candidates_observed,
     7390                        'candidates' => array_map(static function ($candidate) {
     7391                            return [
     7392                                'event_id' => (int) $candidate['id'],
     7393                                'discovered_by' => isset($candidate['_discovered_by']) ? $candidate['_discovered_by'] : [],
     7394                                'source_id' => (int) $candidate['source_id'],
     7395                                'source_key' => isset($candidate['candidate_source_key']) ? (string) $candidate['candidate_source_key'] : null,
     7396                                'adapter_key' => isset($candidate['candidate_adapter_key']) ? (string) $candidate['candidate_adapter_key'] : null,
     7397                                'product_id' => (int) $candidate['product_id'],
     7398                                'record_type' => (string) $candidate['record_type'],
     7399                                'observed_views' => (int) $candidate['observed_views'],
     7400                                'applied_views' => (int) $candidate['applied_views'],
     7401                                'period_start' => $candidate['period_start'],
     7402                                'period_end' => $candidate['period_end'],
     7403                            ];
     7404                        }, !empty($possible_overlap_events) ? $possible_overlap_events : [$possible_overlap_event]),
     7405                        'cross_source_assessment' => $cross_source_assessment,
     7406                        'partial_overlap_profiles' => $partial_overlap_profiles,
     7407                        'product_source_synthesis' => $product_source_synthesis,
     7408                        'product_reconciliation_summary' => $product_reconciliation_summary,
     7409                        'source_fingerprint' => $source_fingerprint !== '' ? $source_fingerprint : null,
     7410                        'adapter_overlap_evidence' => $adapter_overlap_evidence,
     7411                        'counting_policy' => 'preserve_both_pending_source_specific_evidence',
     7412                    ]);
     7413                    $reconciliation_inserted = statpl_db_insert($this->view_source_reconciliation_table, [
     7414                        'event_id' => $new_event_id,
     7415                        'status' => 'possible_overlap',
     7416                        'reason_code' => $possible_overlap_reason_code,
     7417                        'reason' => 'A different source contains one or more potentially overlapping records. All matching candidates were retained as evidence; the contribution was preserved and counted because no automatic suppression was justified without stronger source-specific evidence.',
     7418                        'matched_event_id' => (int) $possible_overlap_event['id'],
     7419                        'evidence' => $evidence,
     7420                        'algorithm_version' => '1.6.0',
     7421                        'created_at' => $now,
     7422                        'updated_at' => $now,
     7423                    ]);
     7424                    $history_inserted = $reconciliation_inserted === 1 ? statpl_db_insert($this->view_source_reconciliation_history_table, [
     7425                        'event_id' => $new_event_id,
     7426                        'previous_status' => null,
     7427                        'new_status' => 'possible_overlap',
     7428                        'reason_code' => $possible_overlap_reason_code,
     7429                        'reason' => 'Potential partial overlap recorded for review. Both source contributions remain counted until source-specific evidence justifies adjustment.',
     7430                        'matched_event_id' => (int) $possible_overlap_event['id'],
     7431                        'evidence' => $evidence,
     7432                        'algorithm_version' => '1.6.0',
     7433                        'created_at' => $now,
     7434                    ]) : false;
     7435                    if ($reconciliation_inserted !== 1 || $history_inserted !== 1) {
     7436                        if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     7437                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7438                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7439                    }
     7440                        $failed++;
     7441                        $failure = $this->record_generic_historical_view_failure(
     7442                            $source_id,
     7443                            $origin_key,
     7444                            $record_key,
     7445                            $product_id,
     7446                            'Could not persist possible-overlap reconciliation evidence: ' . $wpdb->last_error,
     7447                            $batch_start_checkpoint,
     7448                            3
     7449                        , $origin_lock);
     7450                        if (empty($failure['persisted'])) {
     7451                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7452                            $checkpoint_persistence_failed = true;
     7453                    break;
     7454                        }
     7455                        if (!$failure['blocked']) {
     7456                            $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     7457                        }
     7458                        continue;
     7459                    }
     7460                }
     7461                if ($applied_views > 0) {
     7462                    $total_result = statpl_db_query(statpl_db_prepare(
     7463                        "INSERT INTO {$this->views_table} (product_id, total_views) VALUES (%d, %d)
     7464                         ON DUPLICATE KEY UPDATE total_views = total_views + VALUES(total_views)",
     7465                        $product_id,
     7466                        $applied_views
     7467                    ));
     7468                    if ($total_result === false) {
     7469                        if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     7470                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7471                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7472                    }
     7473                        $failed++;
     7474                        $failure = $this->record_generic_historical_view_failure(
     7475                            $source_id,
     7476                            $origin_key,
     7477                            $record_key,
     7478                            $product_id,
     7479                            'Could not update product view totals: ' . $wpdb->last_error,
     7480                            $batch_start_checkpoint,
     7481                            3
     7482                        , $origin_lock);
     7483                        if (empty($failure['persisted'])) {
     7484                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7485                            $checkpoint_persistence_failed = true;
     7486                    break;
     7487                        }
     7488                        if (!$failure['blocked']) {
     7489                            $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     7490                        }
     7491                        continue;
     7492                    }
     7493                }
     7494                $origin_session_current = $this->generic_advisory_lock_session_is_current($origin_lock);
     7495                $fingerprint_session_current = !is_array($reconciliation_lock_state)
     7496                    || empty($reconciliation_lock_state['acquired'])
     7497                    || $this->generic_advisory_lock_session_is_current($reconciliation_lock_state);
     7498                if (!$origin_session_current || !$fingerprint_session_current) {
     7499                    // The transaction belongs to the origin-lock connection. A lost
     7500                    // advisory lock does not itself mean the DB connection changed.
     7501                    // Never send ROLLBACK to a transparently reconnected session.
     7502                    if ($transaction_open && !$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     7503                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7504                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7505                    }
     7506                    $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7507                    return [
     7508                        'status' => 'transaction_lock_session_lost',
     7509                        'processed' => $processed,
     7510                        'duplicates' => $duplicates,
     7511                        'failed' => $failed,
     7512                    ];
     7513                }
     7514
     7515                if (statpl_db_query('COMMIT') === false) {
     7516                    $commit_error = (string) $wpdb->last_error;
     7517
     7518                    // A failed COMMIT acknowledgement is ambiguous when WordPress has
     7519                    // transparently reconnected: the server may have committed the
     7520                    // transaction before the client lost its connection. Do not issue
     7521                    // ledger writes on a replacement session or classify the record
     7522                    // as failed. Replaying the same record is safe because the source
     7523                    // event's unique key and transactional totals update are idempotent.
     7524                    $origin_session_after_commit = $this->generic_advisory_lock_session_is_current($origin_lock);
     7525                    $fingerprint_session_after_commit = !is_array($reconciliation_lock_state)
     7526                        || empty($reconciliation_lock_state['acquired'])
     7527                        || $this->generic_advisory_lock_session_is_current($reconciliation_lock_state);
     7528                    if (!$origin_session_after_commit || !$fingerprint_session_after_commit) {
     7529                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7530                        return [
     7531                            'status' => 'commit_outcome_unknown',
     7532                            'processed' => $processed,
     7533                            'duplicates' => $duplicates,
     7534                            'failed' => $failed,
     7535                        ];
     7536                    }
     7537
     7538                    // The original session is still alive, so the failed COMMIT can
     7539                    // be rolled back and recorded as a normal retryable row failure.
     7540                    if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     7541                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7542                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7543                    }
     7544                    $failed++;
     7545                    $failure = $this->record_generic_historical_view_failure(
     7546                        $source_id,
     7547                        $origin_key,
     7548                        $record_key,
     7549                        $product_id,
     7550                        'Could not commit imported source event: ' . $commit_error,
     7551                        $batch_start_checkpoint,
     7552                        3
     7553                    , $origin_lock);
     7554                    if (empty($failure['persisted'])) {
     7555                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7556                        $checkpoint_persistence_failed = true;
     7557                        break;
     7558                    }
     7559                    if (!$failure['blocked']) {
     7560                        $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     7561                    }
     7562                    continue;
     7563                }
     7564                $transaction_open = false;
     7565                $processed++;
     7566            } elseif ($inserted === false && stripos((string) $wpdb->last_error, 'duplicate') !== false) {
     7567                if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     7568                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7569                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7570                    }
     7571                // A repeated source_record_key is idempotent only when the stored
     7572                // normalized payload is identical. A changed payload under the same
     7573                // key signals an adapter/source inconsistency and must not be silently
     7574                // accepted as an ordinary duplicate.
     7575                $existing_event = statpl_db_get_row(statpl_db_prepare(
     7576                    "SELECT id, product_id, record_type, observed_views, period_start, period_end, source_fingerprint, status
     7577                     FROM {$this->view_source_events_table}
     7578                     WHERE source_id = %d AND source_record_hash = %s
     7579                     LIMIT 1",
     7580                    $source_id,
     7581                    $record_hash
     7582                ), ARRAY_A);
     7583                if (!empty($wpdb->last_error) || !is_array($existing_event)) {
     7584                    $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7585                    return ['status' => 'duplicate_verification_failed', 'processed' => $processed, 'duplicates' => $duplicates];
     7586                }
     7587                $existing_period_start = $existing_event['period_start'] !== null ? (string) $existing_event['period_start'] : null;
     7588                $existing_period_end = $existing_event['period_end'] !== null ? (string) $existing_event['period_end'] : null;
     7589                $existing_fingerprint = $existing_event['source_fingerprint'] !== null ? (string) $existing_event['source_fingerprint'] : '';
     7590                $same_existing_payload = (int) $existing_event['product_id'] === $product_id
     7591                    && (string) $existing_event['record_type'] === $record_type
     7592                    && (int) $existing_event['observed_views'] === $views
     7593                    && $existing_period_start === $period_start
     7594                    && $existing_period_end === $period_end
     7595                    && $existing_fingerprint === $source_fingerprint;
     7596                if ($same_existing_payload) {
     7597                    // A retry must not rewrite reconciliation evidence or append
     7598                    // another history row. Verify the already committed audit record
     7599                    // before accepting the source event as a complete duplicate.
     7600                    $existing_event_id = isset($existing_event['id']) ? (int) $existing_event['id'] : 0;
     7601                    $existing_event_status = isset($existing_event['status']) ? (string) $existing_event['status'] : '';
     7602                    if (in_array($existing_event_status, ['possible_overlap', 'reconciled'], true)) {
     7603                        $existing_audit = statpl_db_get_row(statpl_db_prepare(
     7604                            "SELECT status, reason_code, matched_event_id, evidence, algorithm_version
     7605                             FROM {$this->view_source_reconciliation_table}
     7606                             WHERE event_id = %d
     7607                             LIMIT 1",
     7608                            $existing_event_id
     7609                        ), ARRAY_A);
     7610                        if (!empty($wpdb->last_error) || !is_array($existing_audit)) {
     7611                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7612                            return [
     7613                                'status' => 'duplicate_audit_missing_or_unreadable',
     7614                                'processed' => $processed,
     7615                                'duplicates' => $duplicates,
     7616                            ];
     7617                        }
     7618                        $existing_audit_evidence = !empty($existing_audit['evidence'])
     7619                            ? json_decode((string) $existing_audit['evidence'], true)
     7620                            : null;
     7621                        $audit_matches_event = is_array($existing_audit_evidence)
     7622                            && isset($existing_audit_evidence['source_id'])
     7623                            && (int) $existing_audit_evidence['source_id'] === (int) $source_id;
     7624                        if ($existing_event_status === 'possible_overlap') {
     7625                            $audit_matches_event = $audit_matches_event
     7626                                && isset($existing_audit['matched_event_id'], $existing_audit_evidence['primary_candidate']['event_id'])
     7627                                && (int) $existing_audit['matched_event_id'] === (int) $existing_audit_evidence['primary_candidate']['event_id']
     7628                                && (int) $existing_audit_evidence['primary_candidate']['event_id'] > 0;
     7629                        } elseif ($existing_event_status === 'reconciled') {
     7630                            $audit_matches_event = $audit_matches_event
     7631                                && (string) $existing_audit['status'] === 'matched_exact'
     7632                                && isset($existing_audit['matched_event_id'])
     7633                                && (int) $existing_audit['matched_event_id'] > 0;
     7634                        }
     7635                        if (!$audit_matches_event) {
     7636                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7637                            return [
     7638                                'status' => 'duplicate_audit_integrity_mismatch',
     7639                                'processed' => $processed,
     7640                                'duplicates' => $duplicates,
     7641                            ];
     7642                        }
     7643
     7644                        // The current reconciliation row and its latest history
     7645                        // snapshot must agree. History is verified, never synthesized
     7646                        // or repaired during a duplicate retry.
     7647                        $latest_history = statpl_db_get_row(statpl_db_prepare(
     7648                            "SELECT new_status, reason_code, matched_event_id, evidence, algorithm_version
     7649                             FROM {$this->view_source_reconciliation_history_table}
     7650                             WHERE event_id = %d
     7651                             ORDER BY id DESC
     7652                             LIMIT 1",
     7653                            $existing_event_id
     7654                        ), ARRAY_A);
     7655                        if (!empty($wpdb->last_error) || !is_array($latest_history)) {
     7656                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7657                            return [
     7658                                'status' => 'duplicate_audit_history_missing_or_unreadable',
     7659                                'processed' => $processed,
     7660                                'duplicates' => $duplicates,
     7661                            ];
     7662                        }
     7663
     7664                        $history_matches_audit = (string) $latest_history['new_status'] === (string) $existing_audit['status']
     7665                            && (string) $latest_history['reason_code'] === (string) $existing_audit['reason_code']
     7666                            && (int) ($latest_history['matched_event_id'] ?? 0) === (int) ($existing_audit['matched_event_id'] ?? 0)
     7667                            && (string) ($latest_history['evidence'] ?? '') === (string) ($existing_audit['evidence'] ?? '')
     7668                            && (string) $latest_history['algorithm_version'] === (string) $existing_audit['algorithm_version'];
     7669
     7670                        // Validate the recorded transition chain in ascending history
     7671                        // order. The first row is treated as the chain anchor because
     7672                        // older installations may not have retained its predecessor.
     7673                        $history_chain = statpl_db_get_results(statpl_db_prepare(
     7674                            "SELECT id, previous_status, new_status
     7675                             FROM {$this->view_source_reconciliation_history_table}
     7676                             WHERE event_id = %d
     7677                             ORDER BY id ASC",
     7678                            $existing_event_id
     7679                        ), ARRAY_A);
     7680                        if (!empty($wpdb->last_error) || !is_array($history_chain) || empty($history_chain)) {
     7681                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7682                            return [
     7683                                'status' => 'duplicate_audit_history_chain_unreadable',
     7684                                'processed' => $processed,
     7685                                'duplicates' => $duplicates,
     7686                            ];
     7687                        }
     7688
     7689                        $history_chain_consistent = true;
     7690                        $previous_new_status = null;
     7691                        $previous_history_id = 0;
     7692                        foreach ($history_chain as $history_entry) {
     7693                            $history_id = isset($history_entry['id']) ? (int) $history_entry['id'] : 0;
     7694                            $new_status = isset($history_entry['new_status']) ? (string) $history_entry['new_status'] : '';
     7695                            $previous_status = isset($history_entry['previous_status']) && $history_entry['previous_status'] !== null
     7696                                ? (string) $history_entry['previous_status']
     7697                                : null;
     7698
     7699                            if ($history_id <= $previous_history_id || $new_status === '') {
     7700                                $history_chain_consistent = false;
     7701                                break;
     7702                            }
     7703                            if ($previous_new_status !== null && $previous_status !== $previous_new_status) {
     7704                                $history_chain_consistent = false;
     7705                                break;
     7706                            }
     7707                            $previous_new_status = $new_status;
     7708                            $previous_history_id = $history_id;
     7709                        }
     7710
     7711                        if (!$history_chain_consistent) {
     7712                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7713                            return [
     7714                                'status' => 'duplicate_audit_history_transition_chain_mismatch',
     7715                                'processed' => $processed,
     7716                                'duplicates' => $duplicates,
     7717                            ];
     7718                        }
     7719
     7720                        if (!$history_matches_audit) {
     7721                            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7722                            return [
     7723                                'status' => 'duplicate_audit_history_integrity_mismatch',
     7724                                'processed' => $processed,
     7725                                'duplicates' => $duplicates,
     7726                            ];
     7727                        }
     7728                    }
     7729                    $duplicates++;
     7730                } else {
     7731                    $failed++;
     7732                    $failure = $this->record_generic_historical_view_failure(
     7733                        $source_id,
     7734                        $origin_key,
     7735                        $record_key,
     7736                        $product_id,
     7737                        'The adapter returned different normalized data for an existing source record key. The original imported event was preserved.',
     7738                        $batch_start_checkpoint,
     7739                        3
     7740                    , $origin_lock);
     7741                    if (empty($failure['persisted'])) {
     7742                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7743                        $checkpoint_persistence_failed = true;
     7744                        break;
     7745                    }
     7746                    if (!$failure['blocked']) {
     7747                        $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     7748                    }
     7749                    continue;
     7750                }
     7751                } else {
     7752                    $error = (string) $wpdb->last_error;
     7753                    if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     7754                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7755                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7756                    }
     7757                    $failed++;
     7758                    $failure = $this->record_generic_historical_view_failure(
     7759                        $source_id,
     7760                        $origin_key,
     7761                        $record_key,
     7762                        $product_id,
     7763                        'Could not persist source event: ' . $error,
     7764                        $batch_start_checkpoint,
     7765                        3
     7766                    , $origin_lock);
     7767                    if (empty($failure['persisted'])) {
     7768                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7769                        $checkpoint_persistence_failed = true;
     7770                    break;
     7771                    }
     7772                    if (!$failure['blocked']) {
     7773                        $this->schedule_generic_historical_failure_retry($failure['retry_delay'], $source_id, $origin_key);
     7774                    }
     7775                    continue;
     7776                }
     7777            } finally {
     7778                if ($transaction_open) {
     7779                    // A changed or unverifiable session means the original transaction
     7780                    // outcome cannot be inspected here. Do not issue transaction control
     7781                    // on a replacement connection; ensure the record is retried instead.
     7782                    if (!$this->generic_db_session_matches_lock($origin_lock)) {
     7783                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7784                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7785                    }
     7786                    if (!$this->rollback_generic_import_transaction($transaction_open, $origin_lock)) {
     7787                        $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7788                        return ['status' => 'rollback_outcome_unknown', 'processed' => $processed, 'duplicates' => $duplicates, 'failed' => $failed];
     7789                    }
     7790                }
     7791                if (is_array($reconciliation_lock_state) && !empty($reconciliation_lock_state['acquired'])) {
     7792                    $this->release_generic_advisory_lock($reconciliation_lock_state, 'cross_source_fingerprint');
     7793                }
     7794            }
     7795
     7796            $failure_cleared = $this->clear_generic_historical_view_failure($source_id, $origin_key, $record_key, $origin_lock);
     7797            if ($failure_cleared && is_array($record) && !empty($record['source_record_key']) && (string) $record['source_record_key'] !== $record_key) {
     7798                $failure_cleared = $this->clear_generic_historical_view_failure($source_id, $origin_key, $record['source_record_key'], $origin_lock, null);
     7799            } elseif ($failure_cleared && (!is_array($record) || empty($record['source_record_key']))) {
     7800                $failure_cleared = $this->clear_generic_historical_view_failure($source_id, $origin_key, wp_json_encode($record), $origin_lock, null);
     7801            }
     7802            if (!$failure_cleared) {
     7803                // The event may already be committed. Idempotency makes replay
     7804                // safe, but the cursor must not move beyond this record until
     7805                // its stale failure-ledger entries have been cleared.
     7806                $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7807                $checkpoint_persistence_failed = true;
     7808                break;
     7809            }
     7810            $next_checkpoint['cursor'] = $cursor;
     7811            $next_checkpoint['processed'] = isset($next_checkpoint['processed']) ? (int) $next_checkpoint['processed'] + 1 : 1;
     7812            unset($normalization_error);
     7813        }
     7814
     7815        if ($checkpoint_persistence_failed) {
     7816            // Do not persist the adapter's batch-level next_checkpoint when a
     7817            // row could not be durably represented in the failure ledger. The
     7818            // next main-import attempt must revisit this batch and this row.
     7819            return [
     7820                'status' => 'failure_ledger_persist_failed',
     7821                'processed' => $processed,
     7822                'duplicates' => $duplicates,
     7823                'failed' => $failed,
     7824                'skipped' => $skipped,
     7825            ];
     7826        }
     7827
     7828        // A connection can be transparently re-established after a mid-batch DB outage.
     7829        // Never persist checkpoint/status progress unless this request still owns the
     7830        // original origin lock session that serialized the batch.
     7831        if (!$this->generic_advisory_lock_session_is_current($origin_lock)) {
     7832            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7833            return [
     7834                'status' => 'checkpoint_blocked_origin_lock_session_lost',
     7835                'processed' => $processed,
     7836                'duplicates' => $duplicates,
     7837                'failed' => $failed,
     7838                'skipped' => $skipped,
     7839            ];
     7840        }
     7841
     7842        // Individual bad records have been durably placed in the failure ledger.
     7843        // Continue advancing the main batch checkpoint; a separate replay worker
     7844        // revisits the saved batch checkpoint without blocking later records.
     7845        if ($retry_checkpoint_override !== null) {
     7846            return [
     7847                'status' => $failed ? 'retry_replay_partial' : 'retry_replay_completed',
     7848                'processed' => $processed,
     7849                'duplicates' => $duplicates,
     7850                'failed' => $failed,
     7851            ];
     7852        }
     7853
     7854        if (isset($batch['next_checkpoint'])) {
     7855            $next_checkpoint['cursor'] = $batch['next_checkpoint'];
     7856        }
     7857        $has_more = !empty($batch['has_more']);
     7858        $failure_counts = statpl_db_get_row(statpl_db_prepare(
     7859            "SELECT SUM(CASE WHEN status = 'retry' THEN 1 ELSE 0 END) AS pending,
     7860                    SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END) AS blocked
     7861             FROM {$this->view_source_failures_table} WHERE source_id = %d AND origin_key = %s",
     7862            $source_id,
     7863            $origin_key
     7864        ), ARRAY_A);
     7865        if (!empty($wpdb->last_error) || !is_array($failure_counts)) {
     7866            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7867            return ['status' => 'failure_ledger_read_failed', 'processed' => $processed, 'duplicates' => $duplicates];
     7868        }
     7869        $pending_failures = (int) $failure_counts['pending'];
     7870        $blocked_failures = (int) $failure_counts['blocked'];
     7871        $origin_status = $has_more ? 'importing' : (($pending_failures > 0 || $blocked_failures > 0) ? ($blocked_failures > 0 ? 'needs_attention' : 'retry_wait') : 'completed');
     7872        if (!$this->generic_advisory_lock_session_is_current($origin_lock)) {
     7873            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7874            return ['status' => 'checkpoint_blocked_origin_lock_session_lost', 'processed' => $processed, 'duplicates' => $duplicates];
     7875        }
     7876
     7877        $updates = [
     7878            'checkpoint_data' => $next_checkpoint,
     7879            'last_error' => ($pending_failures > 0 || $blocked_failures > 0) ? 'One or more historical records remain unresolved; the main import continues independently.' : null,
     7880            'status' => $origin_status,
     7881        ];
     7882        if (!$has_more && $pending_failures === 0 && $blocked_failures === 0) {
     7883            $updates['completed_at'] = current_time('mysql');
     7884        } else {
     7885            $updates['completed_at'] = null;
     7886        }
     7887        $saved = $this->update_view_source_origin_state($source_id, $origin_key, $updates, $origin_lock);
     7888        if (empty($saved['status']) || $saved['status'] !== 'updated') {
     7889            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7890            return ['status' => 'checkpoint_write_failed', 'processed' => $processed, 'duplicates' => $duplicates];
     7891        }
     7892
     7893        // A successful statpl_db_query() confirms that MySQL accepted the UPDATE,
     7894        // but a worker must not report durable progress until the exact state is
     7895        // readable again. This also handles ambiguous acknowledgements and
     7896        // storage-layer anomalies without advancing the scheduler to the next
     7897        // batch on an unverified checkpoint.
     7898        $persisted_state = $this->get_view_source_origin_state($source_id, $origin_key);
     7899        $checkpoint_is_persisted = isset($persisted_state['status'])
     7900            && $persisted_state['status'] === 'found'
     7901            && isset($persisted_state['checkpoint'])
     7902            && wp_json_encode($persisted_state['checkpoint']) === wp_json_encode($next_checkpoint)
     7903            && isset($persisted_state['origin_status'])
     7904            && $persisted_state['origin_status'] === $origin_status;
     7905        $completion_is_persisted = array_key_exists('completed_at', $updates)
     7906            && ((array_key_exists('completed_at', $persisted_state) && $updates['completed_at'] === null && $persisted_state['completed_at'] === null)
     7907                || (array_key_exists('completed_at', $persisted_state) && $updates['completed_at'] !== null && $persisted_state['completed_at'] === $updates['completed_at']));
     7908        if (!$checkpoint_is_persisted || !$completion_is_persisted || !$this->generic_advisory_lock_session_is_current($origin_lock)) {
     7909            $this->schedule_generic_historical_view_import_batch(60, $source_id, $origin_key);
     7910            return [
     7911                'status' => 'checkpoint_write_unverified',
     7912                'processed' => $processed,
     7913                'duplicates' => $duplicates,
     7914            ];
     7915        }
     7916
     7917        if ($has_more) {
     7918            $this->schedule_generic_historical_view_import_batch(5, $source_id, $origin_key);
     7919        }
     7920        if ($pending_failures > 0) {
     7921            $this->schedule_generic_historical_failure_retry(60, $source_id, $origin_key);
     7922        }
     7923        return [
     7924            'status' => $has_more ? 'batch_completed' : ($pending_failures > 0 ? 'completed_with_pending_retries' : ($blocked_failures > 0 ? 'completed_with_unresolved_records' : 'completed')),
     7925            'processed' => $processed,
     7926            'duplicates' => $duplicates,
     7927            'checkpoint' => $next_checkpoint,
     7928        ];
     7929    }
     7930
     7931    private function register_view_source($source_key, $display_name, $adapter_version, $granularity, $status = 'detected', $adapter_key = null) {
     7932        global $wpdb;
     7933
     7934        $source_key = (string) $source_key;
     7935        $display_name = sanitize_text_field($display_name);
     7936        $adapter_version = sanitize_text_field($adapter_version);
     7937        $granularity = (string) $granularity;
     7938        $status = (string) $status;
     7939        $adapter_key = $adapter_key === null ? null : sanitize_key((string) $adapter_key);
     7940
     7941        if (
     7942            ($adapter_key !== null && ($adapter_key === '' || strlen($adapter_key) > 100))
     7943            ||
     7944            !preg_match('/^[a-z0-9_-]{1,100}$/D', $source_key)
     7945            || $display_name === ''
     7946            || $adapter_version === ''
     7947            || !preg_match('/^[a-z0-9_-]{1,20}$/D', $granularity)
     7948            || !preg_match('/^[a-z0-9_-]{1,20}$/D', $status)
     7949            || strlen($display_name) > 191
     7950            || strlen($adapter_version) > 32
     7951        ) {
     7952            return [
     7953                'status' => 'invalid_input',
     7954                'source_id' => 0,
     7955            ];
     7956        }
     7957
     7958        $now = current_time('mysql');
     7959
     7960        $result = statpl_db_query(statpl_db_prepare(
     7961            "INSERT INTO {$this->view_sources_table}
     7962                (source_key, adapter_key, display_name, adapter_version, granularity, status, created_at, updated_at)
     7963             VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
     7964             ON DUPLICATE KEY UPDATE
     7965                display_name = IF(VALUES(adapter_key) IS NULL OR adapter_key IS NULL OR adapter_key = VALUES(adapter_key), VALUES(display_name), display_name),
     7966                adapter_version = IF(VALUES(adapter_key) IS NULL OR adapter_key IS NULL OR adapter_key = VALUES(adapter_key), VALUES(adapter_version), adapter_version),
     7967                granularity = IF(VALUES(adapter_key) IS NULL OR adapter_key IS NULL OR adapter_key = VALUES(adapter_key), VALUES(granularity), granularity),
     7968                updated_at = IF(VALUES(adapter_key) IS NULL OR adapter_key IS NULL OR adapter_key = VALUES(adapter_key), VALUES(updated_at), updated_at)",
     7969            $source_key,
     7970            $adapter_key,
     7971            $display_name,
     7972            $adapter_version,
     7973            $granularity,
     7974            $status,
     7975            $now,
     7976            $now
     7977        ));
     7978
     7979        if ($result === false || $wpdb->last_error !== '') {
     7980            return [
     7981                'status' => 'database_error',
     7982                'source_id' => 0,
     7983                'error' => $wpdb->last_error,
     7984            ];
     7985        }
     7986
     7987        $source_id = statpl_db_get_var(
     7988            statpl_db_prepare(
     7989                "SELECT id FROM {$this->view_sources_table} WHERE source_key = %s",
     7990                $source_key
     7991            )
     7992        );
     7993
     7994        if (!empty($wpdb->last_error) || !$source_id) {
     7995            return [
     7996                'status' => 'database_error',
     7997                'source_id' => 0,
     7998                'error' => $wpdb->last_error !== ''
     7999                    ? $wpdb->last_error
     8000                    : 'Registered source ID could not be retrieved.',
     8001            ];
     8002        }
     8003
     8004        if ($adapter_key !== null) {
     8005            $ownership_result = statpl_db_query(
     8006                statpl_db_prepare(
     8007                    "UPDATE {$this->view_sources_table}
     8008                     SET adapter_key = %s
     8009                     WHERE id = %d AND (adapter_key IS NULL OR adapter_key = %s)",
     8010                    $adapter_key,
     8011                    (int) $source_id,
     8012                    $adapter_key
     8013                )
     8014            );
     8015            if ($ownership_result === false || $wpdb->last_error !== '') {
     8016                return [
     8017                    'status' => 'database_error',
     8018                    'source_id' => 0,
     8019                    'error' => $wpdb->last_error,
     8020                ];
     8021            }
     8022
     8023            $stored_adapter_key = statpl_db_get_var(statpl_db_prepare(
     8024                "SELECT adapter_key FROM {$this->view_sources_table} WHERE id = %d",
     8025                (int) $source_id
     8026            ));
     8027            if ($wpdb->last_error !== '') {
     8028                return [
     8029                    'status' => 'database_error',
     8030                    'source_id' => 0,
     8031                    'error' => $wpdb->last_error,
     8032                ];
     8033            }
     8034            if ((string) $stored_adapter_key !== $adapter_key) {
     8035                return [
     8036                    'status' => 'adapter_mismatch',
     8037                    'source_id' => 0,
     8038                ];
     8039            }
     8040        }
     8041
     8042        return [
     8043            'status' => 'registered',
     8044            'source_id' => (int) $source_id,
     8045        ];
     8046    }
     8047
     8048    private function register_view_source_origin($source_id, $origin_key, $adapter_key, $capabilities = [], $detection_status = 'unknown') {
     8049        global $wpdb;
     8050
     8051        $source_id = absint($source_id);
     8052        $origin_key = (string) $origin_key;
     8053        $adapter_key = (string) $adapter_key;
     8054        $detection_status = (string) $detection_status;
     8055
     8056        if (
     8057            !$source_id
     8058            || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key)
     8059            || !preg_match('/^[a-z0-9_-]{1,100}$/D', $adapter_key)
     8060            || !in_array($detection_status, ['unknown', 'available', 'missing_table', 'incompatible_schema', 'database_error'], true)
     8061            || !is_array($capabilities)
     8062        ) {
     8063            return [
     8064                'status' => 'invalid_input',
     8065                'source_id' => $source_id,
     8066                'origin_key' => $origin_key,
     8067            ];
     8068        }
     8069
     8070        $source_exists = statpl_db_get_var(
     8071            statpl_db_prepare(
     8072                "SELECT id FROM {$this->view_sources_table} WHERE id = %d",
     8073                $source_id
     8074            )
     8075        );
     8076
     8077        if (!empty($wpdb->last_error)) {
     8078            return [
     8079                'status' => 'database_error',
     8080                'source_id' => $source_id,
     8081                'origin_key' => $origin_key,
     8082                'error' => $wpdb->last_error,
     8083            ];
     8084        }
     8085
     8086        if (!$source_exists) {
     8087            return [
     8088                'status' => 'source_not_found',
     8089                'source_id' => $source_id,
     8090                'origin_key' => $origin_key,
     8091            ];
     8092        }
     8093
     8094        // An adapter identity is part of the immutable meaning of an origin's
     8095        // snapshot and checkpoint. Never silently replace it after initialization.
     8096        $existing_origin = statpl_db_get_row(
     8097            statpl_db_prepare(
     8098                "SELECT adapter_key, snapshot_data, checkpoint_data, status
     8099                 FROM {$this->view_source_origins_table}
     8100                 WHERE source_id = %d AND origin_key = %s",
     8101                $source_id,
     8102                $origin_key
     8103            ),
     8104            ARRAY_A
     8105        );
     8106
     8107        if (!empty($wpdb->last_error)) {
     8108            return [
     8109                'status' => 'database_error',
     8110                'source_id' => $source_id,
     8111                'origin_key' => $origin_key,
     8112                'error' => $wpdb->last_error,
     8113            ];
     8114        }
     8115
     8116        $existing_source_events = 0;
     8117        $existing_origin_failures = 0;
     8118        if (is_array($existing_origin) && isset($existing_origin['adapter_key']) && (string) $existing_origin['adapter_key'] !== $adapter_key) {
     8119            $existing_source_events = (int) statpl_db_get_var(statpl_db_prepare(
     8120                "SELECT COUNT(*) FROM {$this->view_source_events_table} WHERE source_id = %d",
     8121                $source_id
     8122            ));
     8123            if (!empty($wpdb->last_error)) {
     8124                return [
     8125                    'status' => 'database_error',
     8126                    'source_id' => $source_id,
     8127                    'origin_key' => $origin_key,
     8128                    'error' => $wpdb->last_error,
     8129                ];
     8130            }
     8131            $existing_origin_failures = (int) statpl_db_get_var(statpl_db_prepare(
     8132                "SELECT COUNT(*) FROM {$this->view_source_failures_table} WHERE source_id = %d AND origin_key = %s",
     8133                $source_id,
     8134                $origin_key
     8135            ));
     8136            if (!empty($wpdb->last_error)) {
     8137                return [
     8138                    'status' => 'database_error',
     8139                    'source_id' => $source_id,
     8140                    'origin_key' => $origin_key,
     8141                    'error' => $wpdb->last_error,
     8142                ];
     8143            }
     8144        }
     8145
     8146        if (
     8147            is_array($existing_origin)
     8148            && isset($existing_origin['adapter_key'])
     8149            && (string) $existing_origin['adapter_key'] !== $adapter_key
     8150            && (
     8151                !empty($existing_origin['snapshot_data'])
     8152                || !empty($existing_origin['checkpoint_data'])
     8153                || !in_array($existing_origin['status'], ['pending', 'unavailable'], true)
     8154                || $existing_source_events > 0
     8155                || $existing_origin_failures > 0
     8156            )
     8157        ) {
     8158            $message = sprintf(
     8159                'Historical adapter identity changed from "%1$s" to "%2$s" after this origin was initialized. Existing historical data and checkpoints were preserved; the replacement adapter was not allowed to reinterpret them.',
     8160                sanitize_key((string) $existing_origin['adapter_key']),
     8161                sanitize_key($adapter_key)
     8162            );
     8163            // A replacement adapter cannot safely inherit the old adapter's
     8164            // snapshot or checkpoint. Keep the origin automatically unavailable
     8165            // (rather than requiring administrator intervention), while normal
     8166            // discovery continues to look for the original adapter. If that
     8167            // original identity returns, the ordinary registration path can resume
     8168            // from the preserved checkpoint without reinterpreting historical data.
     8169            statpl_db_update(
     8170                $this->view_source_origins_table,
     8171                [
     8172                    'status' => 'unavailable',
     8173                    'last_error' => $message,
     8174                    'updated_at' => current_time('mysql'),
     8175                ],
     8176                [
     8177                    'source_id' => $source_id,
     8178                    'origin_key' => $origin_key,
     8179                ],
     8180                ['%s', '%s', '%s'],
     8181                ['%d', '%s']
     8182            );
     8183            if (!empty($wpdb->last_error)) {
     8184                return [
     8185                    'status' => 'database_error',
     8186                    'source_id' => $source_id,
     8187                    'origin_key' => $origin_key,
     8188                    'error' => $wpdb->last_error,
     8189                ];
     8190            }
     8191            return [
     8192                'status' => 'adapter_mismatch',
     8193                'source_id' => $source_id,
     8194                'origin_key' => $origin_key,
     8195            ];
     8196        }
     8197
     8198        $capabilities_json = wp_json_encode($capabilities);
     8199
     8200        if (!is_string($capabilities_json)) {
     8201            return [
     8202                'status' => 'invalid_capabilities',
     8203                'source_id' => $source_id,
     8204                'origin_key' => $origin_key,
     8205            ];
     8206        }
     8207
     8208        $now = current_time('mysql');
     8209
     8210        $result = statpl_db_query(
     8211            statpl_db_prepare(
     8212                "INSERT INTO {$this->view_source_origins_table}
     8213                    (source_id, origin_key, adapter_key, capabilities, detection_status, status, updated_at)
     8214                 VALUES (%d, %s, %s, %s, %s, %s, %s)
     8215                 ON DUPLICATE KEY UPDATE
     8216                    adapter_key = VALUES(adapter_key),
     8217                    capabilities = VALUES(capabilities),
     8218                    detection_status = %s,
     8219                    status = CASE
     8220                        WHEN VALUES(detection_status) <> 'available' AND status NOT IN ('completed', 'needs_attention') THEN 'unavailable'
     8221                        WHEN VALUES(detection_status) = 'available' AND status = 'unavailable' THEN 'pending'
     8222                        ELSE status
     8223                    END,
     8224                    updated_at = %s",
     8225                $source_id,
     8226                $origin_key,
     8227                $adapter_key,
     8228                $capabilities_json,
     8229                $detection_status,
     8230                $detection_status === 'available' ? 'pending' : 'unavailable',
     8231                $now,
     8232                $detection_status,
     8233                $now
     8234            )
     8235        );
     8236
     8237        if ($result === false || $wpdb->last_error !== '') {
     8238            return [
     8239                'status' => 'database_error',
     8240                'source_id' => $source_id,
     8241                'origin_key' => $origin_key,
     8242                'error' => $wpdb->last_error,
     8243            ];
     8244        }
     8245
     8246        return [
     8247            'status' => 'registered',
     8248            'source_id' => $source_id,
     8249            'origin_key' => $origin_key,
     8250        ];
     8251    }
     8252
     8253    private function get_view_source_origin_state($source_id, $origin_key) {
     8254        global $wpdb;
     8255
     8256        $source_id = absint($source_id);
     8257        $origin_key = is_string($origin_key) ? trim($origin_key) : '';
     8258
     8259        if (
     8260            !$source_id
     8261            || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key)
     8262        ) {
     8263            return [
     8264                'status' => 'invalid_input',
     8265            ];
     8266        }
     8267
     8268        $row = statpl_db_get_row(
     8269            statpl_db_prepare(
     8270                "SELECT
     8271                    source_id,
     8272                    origin_key,
     8273                    adapter_key,
     8274                    capabilities,
     8275                    snapshot_data,
     8276                    checkpoint_data,
     8277                    detection_status,
     8278                    status,
     8279                    last_error,
     8280                    started_at,
     8281                    completed_at,
     8282                    updated_at
     8283                 FROM {$this->view_source_origins_table}
     8284                 WHERE source_id = %d
     8285                   AND origin_key = %s",
     8286                $source_id,
     8287                $origin_key
     8288            ),
     8289            ARRAY_A
     8290        );
     8291
     8292        if (!empty($wpdb->last_error)) {
     8293            return [
     8294                'status' => 'database_error',
     8295                'error' => $wpdb->last_error,
     8296            ];
     8297        }
     8298
     8299        if (!$row) {
     8300            return [
     8301                'status' => 'not_found',
     8302                'source_id' => $source_id,
     8303                'origin_key' => $origin_key,
     8304            ];
     8305        }
     8306
     8307        $capabilities = null;
     8308        if ($row['capabilities'] !== null && $row['capabilities'] !== '') {
     8309            $capabilities = json_decode($row['capabilities'], true);
     8310            if (json_last_error() !== JSON_ERROR_NONE) {
     8311                return [
     8312                    'status' => 'invalid_stored_data',
     8313                    'source_id' => $source_id,
     8314                    'origin_key' => $origin_key,
     8315                    'field' => 'capabilities',
     8316                ];
     8317            }
     8318        }
     8319
     8320        $snapshot = null;
     8321        if ($row['snapshot_data'] !== null && $row['snapshot_data'] !== '') {
     8322            $snapshot = json_decode($row['snapshot_data'], true);
     8323            if (json_last_error() !== JSON_ERROR_NONE) {
     8324                return [
     8325                    'status' => 'invalid_stored_data',
     8326                    'source_id' => $source_id,
     8327                    'origin_key' => $origin_key,
     8328                    'field' => 'snapshot_data',
     8329                ];
     8330            }
     8331        }
     8332
     8333        $checkpoint = null;
     8334        if ($row['checkpoint_data'] !== null && $row['checkpoint_data'] !== '') {
     8335            $checkpoint = json_decode($row['checkpoint_data'], true);
     8336            if (json_last_error() !== JSON_ERROR_NONE) {
     8337                return [
     8338                    'status' => 'invalid_stored_data',
     8339                    'source_id' => $source_id,
     8340                    'origin_key' => $origin_key,
     8341                    'field' => 'checkpoint_data',
     8342                ];
     8343            }
     8344        }
     8345
     8346        return [
     8347            'status' => 'found',
     8348            'source_id' => (int) $row['source_id'],
     8349            'origin_key' => $row['origin_key'],
     8350            'adapter_key' => $row['adapter_key'],
     8351            'capabilities' => $capabilities,
     8352            'snapshot' => $snapshot,
     8353            'checkpoint' => $checkpoint,
     8354            'detection_status' => $row['detection_status'],
     8355            'origin_status' => $row['status'],
     8356            'last_error' => $row['last_error'],
     8357            'started_at' => $row['started_at'],
     8358            'completed_at' => $row['completed_at'],
     8359            'updated_at' => $row['updated_at'],
     8360        ];
     8361    }
     8362
     8363    private function update_view_source_origin_state($source_id, $origin_key, $updates, $lock_context = null) {
     8364        global $wpdb;
     8365
     8366        $source_id = absint($source_id);
     8367        $origin_key = is_string($origin_key) ? trim($origin_key) : '';
     8368
     8369        if (
     8370            !$source_id
     8371            || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key)
     8372            || !is_array($updates)
     8373            || empty($updates)
     8374        ) {
     8375            return [
     8376                'status' => 'invalid_input',
     8377            ];
     8378        }
     8379
     8380        $allowed_fields = [
     8381            'snapshot_data',
     8382            'checkpoint_data',
     8383            'status',
     8384            'last_error',
     8385            'started_at',
     8386            'completed_at',
     8387        ];
     8388
     8389        $columns = [];
     8390        $values = [];
     8391
     8392        foreach ($updates as $field => $value) {
     8393            if (!in_array($field, $allowed_fields, true)) {
     8394                return [
     8395                    'status' => 'invalid_field',
     8396                    'field' => $field,
     8397                ];
     8398            }
     8399
     8400            if (in_array($field, ['snapshot_data', 'checkpoint_data'], true)) {
     8401                if ($value !== null && !is_array($value)) {
     8402                    return [
     8403                        'status' => 'invalid_input',
     8404                        'field' => $field,
     8405                    ];
     8406                }
     8407
     8408                $encoded = $value === null ? null : wp_json_encode($value);
     8409
     8410                if ($value !== null && !is_string($encoded)) {
     8411                    return [
     8412                        'status' => 'encoding_error',
     8413                        'field' => $field,
     8414                    ];
     8415                }
     8416
     8417                $columns[] = "`{$field}` = %s";
     8418                $values[] = $encoded;
     8419                continue;
     8420            }
     8421
     8422            if ($field === 'status') {
     8423                if (
     8424                    !is_string($value)
     8425                    || !preg_match('/^[a-z0-9_-]{1,20}$/D', $value)
     8426                ) {
     8427                    return [
     8428                        'status' => 'invalid_input',
     8429                        'field' => $field,
     8430                    ];
     8431                }
     8432
     8433                $columns[] = "`{$field}` = %s";
     8434                $values[] = $value;
     8435                continue;
     8436            }
     8437
     8438            if ($field === 'last_error') {
     8439                if ($value !== null && !is_string($value)) {
     8440                    return [
     8441                        'status' => 'invalid_input',
     8442                        'field' => $field,
     8443                    ];
     8444                }
     8445
     8446                $columns[] = "`{$field}` = %s";
     8447                $values[] = $value;
     8448                continue;
     8449            }
     8450
     8451            if ($value !== null && (
     8452                !is_string($value)
     8453                || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $value)
     8454            )) {
     8455                return [
     8456                    'status' => 'invalid_input',
     8457                    'field' => $field,
     8458                ];
     8459            }
     8460
     8461            $columns[] = "`{$field}` = %s";
     8462            $values[] = $value;
     8463        }
     8464
     8465        $columns[] = '`updated_at` = %s';
     8466        $values[] = current_time('mysql');
     8467
     8468        $values[] = $source_id;
     8469        $values[] = $origin_key;
     8470
     8471        $lock_name = '';
     8472        if ($lock_context !== null) {
     8473            if (!$this->generic_advisory_lock_session_is_current($lock_context) || empty($lock_context['lock_name'])) {
     8474                return ['status' => 'lock_session_lost'];
     8475            }
     8476            $lock_name = (string) $lock_context['lock_name'];
     8477        }
     8478        // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The optional lock predicate and its matching lock-name argument are both added only when lock_context is present.
     8479        $lock_predicate = $lock_context !== null ? ' AND IS_USED_LOCK(%s) = CONNECTION_ID()' : '';
     8480        if ($lock_context !== null) {
     8481            $values[] = $lock_name;
     8482        }
     8483        // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- SET placeholders and values are appended together from the validated origin-state field allowlist; source_id/origin_key and the optional lock placeholder are appended to the values in matching order.
     8484// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     8485        $result = statpl_db_query(/* phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Placeholder fragments and their values are assembled together from the same fixed allowlists; optional lock predicates and matching values are added together. */ statpl_db_prepare(
     8486            "UPDATE {$this->view_source_origins_table}
     8487             SET " . implode(', ', /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $columns) . "
     8488             WHERE source_id = %d AND origin_key = %s" . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $lock_predicate,
     8489            ...$values
     8490        ));
     8491
     8492        if ($result === false || $wpdb->last_error !== '') {
     8493            return [
     8494                'status' => 'database_error',
     8495                'error' => $wpdb->last_error,
     8496            ];
     8497        }
     8498
     8499        if ($result === 0) {
     8500            if ($lock_context !== null && !$this->generic_advisory_lock_session_is_current($lock_context)) {
     8501                return ['status' => 'lock_session_lost'];
     8502            }
     8503            $exists = statpl_db_get_var(
     8504                statpl_db_prepare(
     8505                    "SELECT 1 FROM {$this->view_source_origins_table}
     8506                     WHERE source_id = %d AND origin_key = %s",
     8507                    $source_id,
     8508                    $origin_key
     8509                )
     8510            );
     8511
     8512            if (!empty($wpdb->last_error)) {
     8513                return [
     8514                    'status' => 'database_error',
     8515                    'error' => $wpdb->last_error,
     8516                ];
     8517            }
     8518
     8519            // The existence read can itself trigger a transparent wpdb reconnect.
     8520            // Revalidate the original lock session after that read before treating
     8521            // a zero-row UPDATE as a successful no-op. Otherwise a replacement
     8522            // connection could make an unperformed state transition look complete.
     8523            if ($lock_context !== null && !$this->generic_advisory_lock_session_is_current($lock_context)) {
     8524                return ['status' => 'lock_session_lost'];
     8525            }
     8526
     8527            if (!$exists) {
     8528                return [
     8529                    'status' => 'not_found',
     8530                    'source_id' => $source_id,
     8531                    'origin_key' => $origin_key,
     8532                ];
     8533            }
     8534        }
     8535
     8536        return [
     8537            'status' => 'updated',
     8538            'source_id' => $source_id,
     8539            'origin_key' => $origin_key,
     8540        ];
     8541    }
     8542
     8543    private function save_view_source_origin_snapshot_if_empty($source_id, $origin_key, $snapshot) {
     8544        global $wpdb;
     8545
     8546        $source_id = absint($source_id);
     8547        $origin_key = is_string($origin_key) ? trim($origin_key) : '';
     8548
     8549        if (
     8550            !$source_id
     8551            || !preg_match('/^[a-z0-9_-]{1,100}$/D', $origin_key)
     8552            || !is_array($snapshot)
     8553            || empty($snapshot)
     8554        ) {
     8555            return [
     8556                'status' => 'invalid_input',
     8557            ];
     8558        }
     8559
     8560        $encoded = wp_json_encode($snapshot);
     8561
     8562        if (!is_string($encoded)) {
     8563            return [
     8564                'status' => 'encoding_error',
     8565            ];
     8566        }
     8567
     8568        $result = statpl_db_query(
     8569            statpl_db_prepare(
     8570                "UPDATE {$this->view_source_origins_table}
     8571                 SET snapshot_data = %s, updated_at = %s
     8572                 WHERE source_id = %d
     8573                 AND origin_key = %s
     8574                 AND snapshot_data IS NULL",
     8575                $encoded,
     8576                current_time('mysql'),
     8577                $source_id,
     8578                $origin_key
     8579            )
     8580        );
     8581
     8582        if ($result === false || $wpdb->last_error !== '') {
     8583            return [
     8584                'status' => 'database_error',
     8585                'error' => $wpdb->last_error,
     8586            ];
     8587        }
     8588
     8589        if ($result === 1) {
     8590            return [
     8591                'status' => 'updated',
     8592                'source_id' => $source_id,
     8593                'origin_key' => $origin_key,
     8594            ];
     8595        }
     8596
     8597        $existing = $this->get_view_source_origin_state($source_id, $origin_key);
     8598
     8599        if ($existing['status'] !== 'found') {
     8600            return $existing;
     8601        }
     8602
     8603        if ($existing['snapshot'] !== null) {
     8604            return [
     8605                'status' => 'already_initialized',
     8606                'source_id' => $source_id,
     8607                'origin_key' => $origin_key,
     8608                'snapshot' => $existing['snapshot'],
     8609            ];
     8610        }
     8611
     8612        return [
     8613            'status' => 'database_error',
     8614            'source_id' => $source_id,
     8615            'origin_key' => $origin_key,
     8616        ];
     8617    }
     8618
     8619    private function initialize_view_source_origin_snapshot($source_id, $origin_key) {
     8620        global $wpdb;
     8621
     8622        $state = $this->get_view_source_origin_state($source_id, $origin_key);
     8623
     8624        if ($state['status'] !== 'found') {
     8625            return $state;
     8626        }
     8627
     8628        if (!empty($state['snapshot'])) {
     8629            return [
     8630                'status' => 'already_initialized',
     8631                'source_id' => (int) $source_id,
     8632                'origin_key' => $origin_key,
     8633                'snapshot' => $state['snapshot'],
     8634            ];
     8635        }
     8636
     8637        if ($state['detection_status'] !== 'available') {
     8638            return [
     8639                'status' => 'origin_unavailable',
     8640                'source_id' => (int) $source_id,
     8641                'origin_key' => $origin_key,
     8642                'detection_status' => $state['detection_status'],
     8643            ];
     8644        }
     8645
     8646        $origin_tables = [
     8647            'slim_stats' => $wpdb->prefix . 'slim_stats',
     8648            'slim_stats_archive' => $wpdb->prefix . 'slim_stats_archive',
     8649        ];
     8650
     8651        if (!isset($origin_tables[$origin_key])) {
     8652            return [
     8653                'status' => 'unsupported_origin',
     8654                'source_id' => (int) $source_id,
     8655                'origin_key' => $origin_key,
     8656            ];
     8657        }
     8658
     8659        $table = $origin_tables[$origin_key];
     8660
     8661        $max_id = statpl_db_get_var(
     8662            "SELECT MAX(id) FROM `{$table}`"
     8663        );
     8664
     8665        if (!empty($wpdb->last_error)) {
     8666            return [
     8667                'status' => 'database_error',
     8668                'source_id' => (int) $source_id,
     8669                'origin_key' => $origin_key,
     8670                'error' => $wpdb->last_error,
     8671            ];
     8672        }
     8673
     8674        $snapshot = [
     8675            'max_id' => $max_id === null ? 0 : (int) $max_id,
     8676            'captured_at' => current_time('mysql'),
     8677        ];
     8678
     8679        $saved = $this->save_view_source_origin_snapshot_if_empty(
     8680            $source_id,
     8681            $origin_key,
     8682            $snapshot
     8683        );
     8684
     8685        if ($saved['status'] === 'already_initialized') {
     8686            return $saved;
     8687        }
     8688
     8689        if ($saved['status'] !== 'updated') {
     8690            return $saved;
     8691        }
     8692
     8693        return [
     8694            'status' => 'initialized',
     8695            'source_id' => (int) $source_id,
     8696            'origin_key' => $origin_key,
     8697            'snapshot' => $snapshot,
     8698        ];
     8699    }
     8700
     8701    private function save_view_source_origin_snapshot($source_id, $origin_key, $snapshot) {
     8702        if (!is_array($snapshot) || empty($snapshot)) {
     8703            return [
     8704                'status' => 'invalid_snapshot',
     8705            ];
     8706        }
     8707
     8708        return $this->update_view_source_origin_state(
     8709            $source_id,
     8710            $origin_key,
     8711            [
     8712                'snapshot_data' => $snapshot,
     8713            ]
     8714        );
     8715    }
     8716
     8717    private function save_view_source_origin_checkpoint($source_id, $origin_key, $checkpoint) {
     8718        if (!is_array($checkpoint) || empty($checkpoint)) {
     8719            return [
     8720                'status' => 'invalid_checkpoint',
     8721            ];
     8722        }
     8723
     8724        return $this->update_view_source_origin_state(
     8725            $source_id,
     8726            $origin_key,
     8727            [
     8728                'checkpoint_data' => $checkpoint,
     8729            ]
     8730        );
     8731    }
     8732
     8733    private function detect_slimstat_view_origins() {
     8734        global $wpdb;
     8735
     8736        $origins = [
     8737            'slim_stats' => $wpdb->prefix . 'slim_stats',
     8738            'slim_stats_archive' => $wpdb->prefix . 'slim_stats_archive',
     8739        ];
     8740
     8741        $required_columns = [
     8742            'id',
     8743            'content_type',
     8744            'content_id',
     8745            'dt',
     8746        ];
     8747
     8748        $results = [];
     8749
     8750        foreach ($origins as $origin_key => $table) {
     8751            $exists = statpl_db_get_var(
     8752                statpl_db_prepare(
     8753                    'SHOW TABLES LIKE %s',
     8754                    $wpdb->esc_like($table)
     8755                )
     8756            );
     8757
     8758            if (!empty($wpdb->last_error)) {
     8759                $results[$origin_key] = [
     8760                    'status' => 'database_error',
     8761                    'error' => $wpdb->last_error,
     8762                ];
     8763                continue;
     8764            }
     8765
     8766            if ($exists !== $table) {
     8767                $results[$origin_key] = [
     8768                    'status' => 'missing_table',
     8769                ];
     8770                continue;
     8771            }
     8772
     8773            $columns = statpl_db_get_col(
     8774                "SHOW COLUMNS FROM `{$table}`",
     8775                0
     8776            );
     8777
     8778            if (!empty($wpdb->last_error)) {
     8779                $results[$origin_key] = [
     8780                    'status' => 'database_error',
     8781                    'error' => $wpdb->last_error,
     8782                ];
     8783                continue;
     8784            }
     8785
     8786            $missing_columns = array_values(
     8787                array_diff($required_columns, $columns)
     8788            );
     8789
     8790            if (!empty($missing_columns)) {
     8791                $results[$origin_key] = [
     8792                    'status' => 'incompatible_schema',
     8793                    'missing_columns' => $missing_columns,
     8794                ];
     8795                continue;
     8796            }
     8797
     8798            $results[$origin_key] = [
     8799                'status' => 'available',
     8800                'capabilities' => [
     8801                    'historical_views' => true,
     8802                    'record_id' => true,
     8803                    'product_reference' => true,
     8804                    'event_timestamp' => true,
     8805                ],
     8806            ];
     8807        }
     8808
     8809        return $results;
     8810    }
     8811
     8812    private function initialize_slimstat_view_source_origins() {
     8813        $registration = $this->register_slimstat_view_source();
     8814
     8815        if (
     8816            !in_array($registration['status'], ['registered', 'partial'], true)
     8817            || empty($registration['source']['source_id'])
     8818        ) {
     8819            return [
     8820                'status' => 'registration_failed',
     8821                'registration' => $registration,
     8822                'origins' => [],
     8823            ];
     8824        }
     8825
     8826        $source_id = (int) $registration['source']['source_id'];
     8827        $origins = [];
     8828        $initialization_failed = false;
     8829        $available_origins = 0;
     8830
     8831        foreach ($registration['origins'] as $origin_key => $origin_data) {
     8832            $origin_registration = isset($origin_data['registration'])
     8833                ? $origin_data['registration']
     8834                : [];
     8835
     8836            if (($origin_registration['status'] ?? '') !== 'registered') {
     8837                $origins[$origin_key] = [
     8838                    'status' => 'registration_failed',
     8839                    'registration' => $origin_registration,
     8840                ];
     8841                $initialization_failed = true;
     8842                continue;
     8843            }
     8844
     8845            $detection_status = $origin_data['detection']['status'] ?? 'unknown';
     8846
     8847            if ($detection_status !== 'available') {
     8848                $origins[$origin_key] = [
     8849                    'status' => 'skipped',
     8850                    'reason' => $detection_status,
     8851                ];
     8852                continue;
     8853            }
     8854
     8855            $available_origins++;
     8856
     8857            $result = $this->initialize_view_source_origin_snapshot(
     8858                $source_id,
     8859                $origin_key
     8860            );
     8861
     8862            $origins[$origin_key] = $result;
     8863
     8864            if (!in_array(
     8865                $result['status'],
     8866                ['initialized', 'already_initialized'],
     8867                true
     8868            )) {
     8869                $initialization_failed = true;
     8870            }
     8871        }
     8872
     8873        if ($available_origins === 0 && !$initialization_failed) {
     8874            $status = 'no_available_origins';
     8875        } else {
     8876            $status = $initialization_failed ? 'partial' : 'initialized';
     8877        }
     8878
     8879        return [
     8880            'status' => $status,
     8881            'source_id' => $source_id,
     8882            'registration' => $registration,
     8883            'origins' => $origins,
     8884        ];
     8885    }
     8886
     8887    private function register_slimstat_view_source() {
     8888        $source = $this->register_view_source(
     8889            'slimstat',
     8890            'SlimStat',
     8891            '1.0.0',
     8892            'event',
     8893            'registered'
     8894        );
     8895
     8896        if ($source['status'] !== 'registered' || empty($source['source_id'])) {
     8897            return [
     8898                'status' => 'source_registration_failed',
     8899                'source' => $source,
     8900                'origins' => [],
     8901            ];
     8902        }
     8903
     8904        $source_id = (int) $source['source_id'];
     8905        $detected_origins = $this->detect_slimstat_view_origins();
     8906        $origins = [];
     8907        $registration_failed = false;
     8908
     8909        foreach ($detected_origins as $origin_key => $detection) {
     8910            $capabilities = isset($detection['capabilities'])
     8911                ? $detection['capabilities']
     8912                : [];
     8913
     8914            $registration = $this->register_view_source_origin(
     8915                $source_id,
     8916                $origin_key,
     8917                'slimstat',
     8918                $capabilities,
     8919                in_array($detection['status'], ['unknown', 'available', 'missing_table', 'incompatible_schema', 'database_error'], true)
     8920                    ? $detection['status']
     8921                    : 'unknown'
     8922            );
     8923
     8924            $origins[$origin_key] = [
     8925                'detection' => $detection,
     8926                'registration' => $registration,
     8927            ];
     8928
     8929            if ($registration['status'] !== 'registered') {
     8930                $registration_failed = true;
     8931            }
     8932        }
     8933
     8934        return [
     8935            'status' => $registration_failed ? 'partial' : 'registered',
     8936            'source' => $source,
     8937            'origins' => $origins,
     8938        ];
     8939    }
     8940
     8941    private function detect_slimstat_source() {
     8942    global $wpdb;
     8943
     8944    // Check whether SlimStat is installed and loaded.
     8945    if (!class_exists('wp_slimstat', false)) {
     8946        return [
     8947            'status' => 'not_installed',
     8948        ];
     8949    }
     8950
     8951    // SlimStat source tables.
     8952    $tables = [
     8953        'active'  => $wpdb->prefix . 'slim_stats',
     8954        'archive' => $wpdb->prefix . 'slim_stats_archive',
     8955    ];
     8956
     8957    $missing_tables = [];
     8958
     8959    // Check that both tables exist.
     8960    foreach ($tables as $key => $table) {
     8961        $exists = statpl_db_get_var(
     8962            statpl_db_prepare(
     8963                'SHOW TABLES LIKE %s',
     8964                $wpdb->esc_like($table)
     8965            )
     8966        );
     8967
     8968        if (!empty($wpdb->last_error)) {
     8969            return [
     8970                'status' => 'database_error',
     8971            ];
     8972        }
     8973
     8974        if ($exists !== $table) {
     8975            $missing_tables[] = $table;
     8976        }
     8977    }
     8978
     8979    if (!empty($missing_tables)) {
     8980        return [
     8981            'status'  => 'missing_tables',
     8982            'missing' => $missing_tables,
     8983        ];
     8984    }
     8985
     8986    // Required columns for historical product-view import.
     8987    $required_columns = [
     8988        'id',
     8989        'content_type',
     8990        'content_id',
     8991        'dt',
     8992    ];
     8993
     8994    $incompatible_tables = [];
     8995
     8996    // Check the required schema in both tables.
     8997    foreach ($tables as $key => $table) {
     8998        $columns = statpl_db_get_col(
     8999            "SHOW COLUMNS FROM `{$table}`",
     9000            0
     9001        );
     9002
     9003        if (!empty($wpdb->last_error)) {
     9004            return [
     9005                'status' => 'database_error',
     9006            ];
     9007        }
     9008
     9009        $missing_columns = array_diff(
     9010            $required_columns,
     9011            $columns
     9012        );
     9013
     9014        if (!empty($missing_columns)) {
     9015            $incompatible_tables[$table] = array_values(
     9016                $missing_columns
     9017            );
     9018        }
     9019    }
     9020
     9021    if (!empty($incompatible_tables)) {
     9022        return [
     9023            'status'          => 'incompatible_schema',
     9024            'missing_columns' => $incompatible_tables,
     9025        ];
     9026    }
     9027
     9028    return [
     9029        'status' => 'available',
     9030        'tables' => $tables,
     9031    ];
     9032}
     9033    private function get_slimstat_view_snapshot() {
     9034    global $wpdb;
     9035
     9036    $source = $this->detect_slimstat_source();
     9037
     9038    if ($source['status'] !== 'available') {
     9039        return $source;
     9040    }
     9041
     9042    $snapshot = [];
     9043
     9044    foreach ($source['tables'] as $key => $table) {
     9045        $max_id = statpl_db_get_var(
     9046    statpl_db_prepare(
     9047        "SELECT MAX(id) FROM `{$table}` WHERE content_type = %s",
     9048        'cpt:product'
     9049      )
     9050   );
     9051
     9052        if (!empty($wpdb->last_error)) {
     9053            return [
     9054                'status' => 'database_error',
     9055            ];
     9056        }
     9057
     9058        $snapshot[$key] = (int) $max_id;
     9059    }
     9060
     9061    return [
     9062        'status'   => 'available',
     9063        'snapshot' => $snapshot,
     9064    ];
     9065}
     9066private function initialize_slimstat_view_import() {
     9067    global $wpdb;
     9068
     9069    $existing = statpl_db_get_var(
     9070        statpl_db_prepare(
     9071            "SELECT source FROM {$this->view_import_state_table} WHERE source = %s",
     9072            'slimstat'
     9073        )
     9074    );
     9075
     9076    if (!empty($wpdb->last_error)) {
     9077        return [
     9078            'status' => 'database_error',
     9079        ];
     9080    }
     9081
     9082    if ($existing === 'slimstat') {
     9083        return [
     9084            'status' => 'already_initialized',
     9085        ];
     9086    }
     9087
     9088    $result = $this->get_slimstat_view_snapshot();
     9089
     9090    if ($result['status'] !== 'available') {
     9091        return $result;
     9092    }
     9093
     9094    $inserted = statpl_db_query(
     9095        statpl_db_prepare(
     9096            "INSERT IGNORE INTO {$this->view_import_state_table}
     9097            (source, snapshot_max_id, snapshot_active_max_id, snapshot_archive_max_id, last_processed_id, status, updated_at)
     9098        VALUES (%s, %d, %d, %d, %d, %s, %s)",
     9099                       'slimstat',
     9100                       max($result['snapshot']['active'], $result['snapshot']['archive']),
     9101                       $result['snapshot']['active'],
     9102                       $result['snapshot']['archive'],
     9103                       0,
     9104                       'pending',
     9105                       current_time('mysql')
     9106        )
     9107    );
     9108
     9109    if (false === $inserted) {
     9110        return [
     9111            'status' => 'database_error',
     9112        ];
     9113    }
     9114
     9115    if (0 === $inserted) {
     9116        $existing = statpl_db_get_var(
     9117            statpl_db_prepare(
     9118                "SELECT source FROM {$this->view_import_state_table} WHERE source = %s",
     9119                'slimstat'
     9120            )
     9121        );
     9122
     9123        if (!empty($wpdb->last_error)) {
     9124            return [
     9125                'status' => 'database_error',
     9126            ];
     9127        }
     9128
     9129        if ($existing === 'slimstat') {
     9130            return [
     9131                'status' => 'already_initialized',
     9132            ];
     9133        }
     9134
     9135        return [
     9136            'status' => 'insert_not_confirmed',
     9137        ];
     9138    }
     9139
     9140    return [
     9141        'status' => 'initialized',
     9142        'snapshot' => $result['snapshot'],
     9143    ];
     9144}
     9145
     9146    /**
     9147     * Retrieve the next historical SlimStat product-view batch.
     9148     * This method is read-only: it never changes the checkpoint or import tables.
     9149     */
     9150    private function get_slimstat_view_import_batch($last_processed_id, $batch_size = 50) {
     9151        global $wpdb;
     9152
     9153        $state = statpl_db_get_row(
     9154            statpl_db_prepare(
     9155                "SELECT snapshot_active_max_id, snapshot_archive_max_id
     9156                 FROM {$this->view_import_state_table}
     9157                 WHERE source = %s",
     9158                'slimstat'
     9159            ),
     9160            ARRAY_A
     9161        );
     9162
     9163        if (!empty($wpdb->last_error)) {
     9164            return [
     9165                'status' => 'database_error',
     9166                'rows'   => [],
     9167            ];
     9168        }
     9169
     9170        if (!is_array($state)) {
     9171            return [
     9172                'status' => 'state_missing',
     9173                'rows'   => [],
     9174            ];
     9175        }
     9176
     9177        $active_max_id  = (int) $state['snapshot_active_max_id'];
     9178        $archive_max_id = (int) $state['snapshot_archive_max_id'];
     9179        $last_processed_id = max(0, (int) $last_processed_id);
     9180        $batch_size = max(1, min(500, (int) $batch_size));
     9181
     9182        $active_table  = $wpdb->prefix . 'slim_stats';
     9183        $archive_table = $wpdb->prefix . 'slim_stats_archive';
     9184
     9185        $sql = "
     9186            SELECT id, product_id, dt, source
     9187            FROM (
     9188                SELECT
     9189                    s.id AS id,
     9190                    s.content_id AS product_id,
     9191                    s.dt AS dt,
     9192                    'active' AS source
     9193                FROM `{$active_table}` s
     9194                WHERE s.content_type = %s
     9195                  AND s.id > %d
     9196                  AND s.id <= %d
     9197
     9198                UNION ALL
     9199
     9200                SELECT
     9201                    a.id AS id,
     9202                    a.content_id AS product_id,
     9203                    a.dt AS dt,
     9204                    'archive' AS source
     9205                FROM `{$archive_table}` a
     9206                WHERE a.content_type = %s
     9207                  AND a.id > %d
     9208                  AND a.id <= %d
     9209                  AND NOT EXISTS (
     9210                      SELECT 1
     9211                      FROM `{$active_table}` s
     9212                      WHERE s.id = a.id
     9213                        AND s.id <= %d
     9214                  )
     9215            ) AS combined
     9216            ORDER BY id ASC
     9217            LIMIT %d
     9218        ";
     9219
     9220        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is a fixed SQL template with value placeholders; execution below passes it through statpl_db_prepare() with the complete value list.
     9221        $rows = statpl_db_get_results(
     9222// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     9223            statpl_db_prepare(
     9224                /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $sql,
     9225                'cpt:product',
     9226                $last_processed_id,
     9227                $active_max_id,
     9228                'cpt:product',
     9229                $last_processed_id,
     9230                $archive_max_id,
     9231                $active_max_id,
     9232                $batch_size
     9233            ),
     9234            ARRAY_A
     9235        );
     9236
     9237        if (!empty($wpdb->last_error)) {
     9238            return [
     9239                'status' => 'database_error',
     9240                'rows'   => [],
     9241            ];
     9242        }
     9243
     9244        return [
     9245            'status' => 'available',
     9246            'rows'   => is_array($rows) ? $rows : [],
     9247        ];
     9248    }
     9249
     9250    /**
     9251     * Import one historical SlimStat product-view event idempotently.
     9252     *
     9253     * The event ledger is the unique source of truth. A guarded multi-table
     9254     * update applies the metrics and marks the event in one atomic statement.
     9255     *
     9256     * The caller is responsible for checkpoint progression and retry handling.
     9257     *
     9258     * @param array $row Historical SlimStat source row.
     9259     * @return string imported|duplicate|failed
     9260     */
     9261    private function import_slimstat_view_event($row) {
     9262        global $wpdb;
     9263
     9264        if (
     9265            !is_array($row)
     9266            || !isset($row['id'], $row['product_id'])
     9267        ) {
     9268            return 'failed';
     9269        }
     9270
     9271        $source_view_id = (int) $row['id'];
     9272        $product_id = (int) $row['product_id'];
     9273
     9274        if ($source_view_id <= 0 || $product_id <= 0) {
     9275            return 'failed';
     9276        }
     9277
     9278        $now = current_time('mysql');
     9279
     9280        // Insert the event as unapplied. Repeating this insert is harmless.
     9281        $ledger_inserted = statpl_db_query(
     9282            statpl_db_prepare(
     9283                "INSERT INTO {$this->view_import_events_table}
     9284                (source, source_view_id, product_id, imported_at, counts_applied)
     9285                VALUES (%s, %d, %d, %s, 0)
     9286                ON DUPLICATE KEY UPDATE
     9287                    source_view_id = VALUES(source_view_id)",
     9288                'slimstat',
     9289                $source_view_id,
     9290                $product_id,
     9291                $now
     9292            )
     9293        );
     9294
     9295        if (false === $ledger_inserted) {
     9296            return 'failed';
     9297        }
     9298
     9299        // Verify the existing event identity; never reassign it to another product.
     9300        $event = statpl_db_get_row(
     9301            statpl_db_prepare(
     9302                "SELECT product_id, counts_applied
     9303                FROM {$this->view_import_events_table}
     9304                WHERE source = %s AND source_view_id = %d",
     9305                'slimstat',
     9306                $source_view_id
     9307            ),
     9308            ARRAY_A
     9309        );
     9310
     9311        if (
     9312            !is_array($event)
     9313            || !isset($event['product_id'], $event['counts_applied'])
     9314            || (int) $event['product_id'] !== $product_id
     9315        ) {
     9316            return 'failed';
     9317        }
     9318
     9319        if (1 === (int) $event['counts_applied']) {
     9320            return 'duplicate';
     9321        }
     9322
     9323        // Ensure both metric rows exist. These writes are safe to repeat.
     9324        $total_ready = statpl_db_query(
     9325            statpl_db_prepare(
     9326                "INSERT INTO {$this->views_table}
     9327                (product_id, total_views)
     9328                VALUES (%d, 0)
     9329                ON DUPLICATE KEY UPDATE product_id = VALUES(product_id)",
     9330                $product_id
     9331            )
     9332        );
     9333
     9334        if (false === $total_ready) {
     9335            return 'failed';
     9336        }
     9337
     9338        $aggregate_ready = statpl_db_query(
     9339            statpl_db_prepare(
     9340                "INSERT INTO {$this->view_imports_table}
     9341                (source, product_id, imported_views, imported_at)
     9342                VALUES (%s, %d, 0, %s)
     9343                ON DUPLICATE KEY UPDATE product_id = VALUES(product_id)",
     9344                'slimstat',
     9345                $product_id,
     9346                $now
     9347            )
     9348        );
     9349
     9350        if (false === $aggregate_ready) {
     9351            return 'failed';
     9352        }
     9353
     9354        /*
     9355         * Apply all three changes atomically.
     9356         * Replaying this statement cannot increment the metrics twice.
     9357         */
     9358        $metrics_updated = statpl_db_query(
     9359            statpl_db_prepare(
     9360                "UPDATE {$this->views_table} t
     9361                INNER JOIN {$this->view_imports_table} i
     9362                    ON i.source = %s AND i.product_id = t.product_id
     9363                INNER JOIN {$this->view_import_events_table} e
     9364                    ON e.source = %s
     9365                    AND e.source_view_id = %d
     9366                    AND e.product_id = t.product_id
     9367                SET
     9368                    t.total_views = t.total_views + 1,
     9369                    i.imported_views = i.imported_views + 1,
     9370                    i.imported_at = %s,
     9371                    e.counts_applied = 1
     9372                WHERE t.product_id = %d
     9373                  AND e.counts_applied = 0",
     9374                'slimstat',
     9375                'slimstat',
     9376                $source_view_id,
     9377                $now,
     9378                $product_id
     9379            )
     9380        );
     9381
     9382        if (false === $metrics_updated) {
     9383            return 'failed';
     9384        }
     9385
     9386        if (3 === $metrics_updated) {
     9387            delete_transient('statpl_global_summary_v2');
     9388            return 'imported';
     9389        }
     9390
     9391        // Verify an ambiguous or concurrent result using the durable marker.
     9392        if (0 === $metrics_updated) {
     9393            $event = statpl_db_get_row(
     9394                statpl_db_prepare(
     9395                    "SELECT product_id, counts_applied
     9396                    FROM {$this->view_import_events_table}
     9397                    WHERE source = %s AND source_view_id = %d",
     9398                    'slimstat',
     9399                    $source_view_id
     9400                ),
     9401                ARRAY_A
     9402            );
     9403
     9404            if (
     9405                is_array($event)
     9406                && isset($event['product_id'], $event['counts_applied'])
     9407                && (int) $event['product_id'] === $product_id
     9408                && 1 === (int) $event['counts_applied']
     9409            ) {
     9410                delete_transient('statpl_global_summary_v2');
     9411                return 'duplicate';
     9412            }
     9413        }
     9414
     9415        return 'failed';
     9416    }
     9417
     9418
     9419    /** Persist an initial SlimStat import failure without counting a replay as another attempt. */
     9420    private function record_slimstat_view_import_failure($row, $error) {
     9421        global $wpdb;
     9422
     9423        $source_view_id = isset($row['id']) ? absint($row['id']) : 0;
     9424        $product_id = isset($row['product_id']) ? absint($row['product_id']) : 0;
     9425
     9426        if ($source_view_id < 1) {
     9427            return false;
     9428        }
     9429
     9430        $error = substr(wp_strip_all_tags((string) $error), 0, 4000);
     9431        if ($error === '') {
     9432            $error = 'SlimStat historical view could not be imported.';
     9433        }
     9434
     9435        $result = statpl_db_query(
     9436            statpl_db_prepare(
     9437                "INSERT INTO {$this->view_import_failures_table}
     9438                    (source, source_view_id, product_id, attempts, last_error, status, updated_at)
     9439                 VALUES (%s, %d, %d, 1, %s, 'retry', %s)
     9440                 ON DUPLICATE KEY UPDATE last_error = VALUES(last_error)",
     9441                'slimstat',
     9442                $source_view_id,
     9443                $product_id,
     9444                $error,
     9445                current_time('mysql')
     9446            )
     9447        );
     9448
     9449        if (false === $result) {
     9450            return false;
     9451        }
     9452        $this->save_historical_import_report_record('slimstat', 'SlimStat', 'slimstat', hash('sha256', "slimstat\0" . $source_view_id), 'retry', 1, 'record_import_failed');
     9453
     9454        $failure = statpl_db_get_row(
     9455            statpl_db_prepare(
     9456                "SELECT product_id
     9457                 FROM {$this->view_import_failures_table}
     9458                 WHERE source = %s AND source_view_id = %d",
     9459                'slimstat',
     9460                $source_view_id
     9461            ),
     9462            ARRAY_A
     9463        );
     9464
     9465        return empty($wpdb->last_error)
     9466            && is_array($failure)
     9467            && isset($failure['product_id'])
     9468            && (int) $failure['product_id'] === $product_id;
     9469    }
     9470
     9471    /** Process independently due SlimStat retries. */
     9472    private function process_slimstat_view_import_retries($lock = null) {
     9473        global $wpdb;
     9474
     9475        // Retry processing is part of the same serialized worker as the main
     9476        // batch. Verify ownership per event, not merely before and after the
     9477        // entire retry loop, because a transparent reconnect can occur midway.
     9478        if (!$this->slimstat_import_lock_is_current($lock)) {
     9479            return false;
     9480        }
     9481
     9482        $now = current_time('mysql');
     9483
     9484        $failures = statpl_db_get_results(
     9485            statpl_db_prepare(
     9486                "SELECT id, source_view_id, product_id, attempts
     9487                 FROM {$this->view_import_failures_table}
     9488                 WHERE source = %s
     9489                   AND status = 'retry'
     9490                   AND (
     9491                       (attempts = 1 AND updated_at <= DATE_SUB(%s, INTERVAL 1 MINUTE))
     9492                       OR
     9493                       (attempts = 2 AND updated_at <= DATE_SUB(%s, INTERVAL 2 MINUTE))
     9494                   )
     9495                 ORDER BY updated_at ASC
     9496                 LIMIT 50",
     9497                'slimstat',
     9498                $now,
     9499                $now
     9500            ),
     9501            ARRAY_A
     9502        );
     9503
     9504        if (!empty($wpdb->last_error) || !is_array($failures)) {
     9505            return false;
     9506        }
     9507
     9508        foreach ($failures as $failure) {
     9509            if (!$this->slimstat_import_lock_is_current($lock)) {
     9510                return false;
     9511            }
     9512
     9513            $result = $this->import_slimstat_view_event([
     9514                'id'         => (int) $failure['source_view_id'],
     9515                'product_id' => (int) $failure['product_id'],
     9516            ]);
     9517
     9518            // The event may have committed immediately before a DB reconnect.
     9519            // Its counts_applied marker makes replay safe, but no retry-ledger
     9520            // mutation may continue on a replacement session without the lock.
     9521            if (!$this->slimstat_import_lock_is_current($lock)) {
     9522                return false;
     9523            }
     9524
     9525            if (in_array($result, ['imported', 'duplicate'], true)) {
     9526                $this->save_historical_import_report_record('slimstat', 'SlimStat', 'slimstat', hash('sha256', "slimstat\0" . (int) $failure['source_view_id']), 'imported', (int) $failure['attempts'], '');
     9527                $deleted = statpl_db_delete(
     9528                    $this->view_import_failures_table,
     9529                    [
     9530                        'id'     => (int) $failure['id'],
     9531                        'source' => 'slimstat',
     9532                    ],
     9533                    ['%d', '%s']
     9534                );
     9535
     9536                if (false === $deleted || !$this->slimstat_import_lock_is_current($lock)) {
     9537                    return false;
     9538                }
     9539
     9540                continue;
     9541            }
     9542
     9543            $error = !empty($wpdb->last_error)
     9544                ? $wpdb->last_error
     9545                : 'SlimStat historical view retry failed.';
     9546
     9547            $updated = statpl_db_query(
     9548                statpl_db_prepare(
     9549                    "UPDATE {$this->view_import_failures_table}
     9550                     SET
     9551                         status = IF(attempts >= 2, 'failed', 'retry'),
     9552                         attempts = attempts + 1,
     9553                         last_error = %s,
     9554                         updated_at = %s
     9555                     WHERE id = %d
     9556                       AND source = %s
     9557                       AND status = 'retry'
     9558                       AND attempts < 3",
     9559                    substr(wp_strip_all_tags((string) $error), 0, 4000),
     9560                    current_time('mysql'),
     9561                    (int) $failure['id'],
     9562                    'slimstat'
     9563                )
     9564            );
     9565
     9566            if (1 !== $updated || !$this->slimstat_import_lock_is_current($lock)) {
     9567                return false;
     9568            }
     9569            $this->save_historical_import_report_record('slimstat', 'SlimStat', 'slimstat', hash('sha256', "slimstat\0" . (int) $failure['source_view_id']), ((int) $failure['attempts'] >= 2 ? 'failed' : 'retry'), (int) $failure['attempts'] + 1, 'record_import_failed');
     9570        }
     9571
     9572        return $this->slimstat_import_lock_is_current($lock);
     9573    }
     9574
     9575    /** Return the delay until the earliest pending SlimStat retry. */
     9576    private function get_slimstat_view_import_retry_delay() {
     9577        global $wpdb;
     9578
     9579        $delay = statpl_db_get_var(
     9580            statpl_db_prepare(
     9581                "SELECT GREATEST(
     9582                    5,
     9583                    TIMESTAMPDIFF(
     9584                        SECOND,
     9585                        %s,
     9586                        CASE
     9587                            WHEN attempts = 1 THEN DATE_ADD(updated_at, INTERVAL 1 MINUTE)
     9588                            ELSE DATE_ADD(updated_at, INTERVAL 2 MINUTE)
     9589                        END
     9590                    )
     9591                 )
     9592                 FROM {$this->view_import_failures_table}
     9593                 WHERE source = %s AND status = 'retry'
     9594                 ORDER BY
     9595                    CASE
     9596                        WHEN attempts = 1 THEN DATE_ADD(updated_at, INTERVAL 1 MINUTE)
     9597                        ELSE DATE_ADD(updated_at, INTERVAL 2 MINUTE)
     9598                    END ASC
     9599                 LIMIT 1",
     9600                current_time('mysql'),
     9601                'slimstat'
     9602            )
     9603        );
     9604
     9605        if (!empty($wpdb->last_error)) {
     9606            return 60;
     9607        }
     9608
     9609        return null === $delay ? 5 : max(5, (int) $delay);
     9610    }
     9611
     9612    /** Advance the SlimStat checkpoint only after the current row is durably resolved. */
     9613    private function update_slimstat_view_import_checkpoint($expected_id, $next_id, $lock) {
     9614        global $wpdb;
     9615
     9616        $expected_id = max(0, (int) $expected_id);
     9617        $next_id = max(0, (int) $next_id);
     9618
     9619        if ($next_id <= $expected_id) {
     9620            return $this->slimstat_import_lock_is_current($lock);
     9621        }
     9622        if (!$this->slimstat_import_lock_is_current($lock)) {
     9623            return false;
     9624        }
     9625
     9626        // Enforce ownership inside the checkpoint UPDATE itself. This closes
     9627        // the check/write race if wpdb reconnects between separate statements.
     9628        $updated = statpl_db_query(
     9629            statpl_db_prepare(
     9630                "UPDATE {$this->view_import_state_table}
     9631                 SET last_processed_id = %d, updated_at = %s
     9632                 WHERE source = %s AND last_processed_id = %d
     9633                   AND CONNECTION_ID() = %s
     9634                   AND IS_USED_LOCK(%s) = CONNECTION_ID()",
     9635                $next_id,
     9636                current_time('mysql'),
     9637                'slimstat',
     9638                $expected_id,
     9639                (string) $lock['connection_id'],
     9640                (string) $lock['lock_name']
     9641            )
     9642        );
     9643
     9644        return 1 === $updated && $this->slimstat_import_lock_is_current($lock);
     9645    }
     9646
     9647    /** Capture and verify the exact MySQL session that owns the SlimStat import lock. */
     9648    private function acquire_slimstat_import_lock($lock_name) {
     9649        global $wpdb;
     9650        try {
     9651            $row = statpl_db_get_row(statpl_db_prepare(
     9652                'SELECT CONNECTION_ID() AS connection_id, GET_LOCK(%s, 0) AS lock_acquired',
     9653                (string) $lock_name
     9654            ), ARRAY_A);
     9655        } catch (Throwable $exception) {
     9656            return ['acquired' => false, 'error' => $exception->getMessage()];
     9657        }
     9658        if (!is_array($row) || !empty($wpdb->last_error)
     9659            || (string) ($row['lock_acquired'] ?? '') !== '1'
     9660            || empty($row['connection_id'])) {
     9661            return ['acquired' => false, 'error' => (string) $wpdb->last_error];
     9662        }
     9663        return [
     9664            'acquired' => true,
     9665            'connection_id' => (string) $row['connection_id'],
     9666            'lock_name' => (string) $lock_name,
     9667        ];
     9668    }
     9669
     9670    /** Verify both connection identity and actual ownership of the SlimStat lock. */
     9671    private function slimstat_import_lock_is_current($lock) {
     9672        global $wpdb;
     9673        if (!is_array($lock) || empty($lock['acquired'])
     9674            || empty($lock['connection_id']) || empty($lock['lock_name'])) {
     9675            return false;
     9676        }
     9677        try {
     9678            $row = statpl_db_get_row(statpl_db_prepare(
     9679                'SELECT CONNECTION_ID() AS connection_id, IS_USED_LOCK(%s) AS lock_owner',
     9680                (string) $lock['lock_name']
     9681            ), ARRAY_A);
     9682        } catch (Throwable $exception) {
     9683            return false;
     9684        }
     9685        return is_array($row) && empty($wpdb->last_error)
     9686            && (string) ($row['connection_id'] ?? '') === (string) $lock['connection_id']
     9687            && (string) ($row['lock_owner'] ?? '') === (string) $lock['connection_id'];
     9688    }
     9689
     9690    /** Release the SlimStat lock only through the original lock-owning session. */
     9691    private function release_slimstat_import_lock($lock) {
     9692        global $wpdb;
     9693        if (!$this->slimstat_import_lock_is_current($lock)) {
     9694            if (defined('WP_DEBUG') && WP_DEBUG) {
     9695                $this->debug_log('Statora Plus: SlimStat advisory lock session changed before release.');
     9696            }
     9697            return false;
     9698        }
     9699        try {
     9700            $released = statpl_db_get_var(statpl_db_prepare(
     9701                'SELECT RELEASE_LOCK(%s)', (string) $lock['lock_name']
     9702            ));
     9703            return (string) $released === '1' && empty($wpdb->last_error);
     9704        } catch (Throwable $exception) {
     9705            if (defined('WP_DEBUG') && WP_DEBUG) {
     9706                $this->debug_log('Statora Plus: SlimStat advisory lock release failed: ' . $exception->getMessage());
     9707            }
     9708            return false;
     9709        }
     9710    }
     9711
     9712    /** Execute one protected SlimStat historical import batch. */
     9713    public function run_slimstat_view_import_batch() {
     9714        global $wpdb;
     9715
     9716        // MySQL advisory lock names must remain within the server's 64-character limit.
     9717        $lock_name = 'statpl_view_import_' . substr(md5($wpdb->prefix), 0, 32);
     9718
     9719        $slimstat_lock = $this->acquire_slimstat_import_lock($lock_name);
     9720
     9721        if (empty($slimstat_lock['acquired'])) {
     9722            $this->schedule_slimstat_view_import_batch(
     9723                !empty($wpdb->last_error) ? 60 : 15
     9724            );
     9725            return;
     9726        }
     9727
     9728        try {
     9729            if (!$this->slimstat_import_lock_is_current($slimstat_lock)) {
     9730                $this->schedule_slimstat_view_import_batch(60);
     9731                return;
     9732            }
     9733            $state = statpl_db_get_row(
     9734                statpl_db_prepare(
     9735                    "SELECT snapshot_max_id, last_processed_id, status
     9736                     FROM {$this->view_import_state_table}
     9737                     WHERE source = %s",
     9738                    'slimstat'
     9739                ),
     9740                ARRAY_A
     9741            );
     9742
     9743            if (!empty($wpdb->last_error)) {
     9744                $this->schedule_slimstat_view_import_batch(60);
     9745                return;
     9746            }
     9747
     9748            if (!is_array($state)) {
     9749                return;
     9750            }
     9751
     9752            if (!in_array($state['status'], ['pending', 'running'], true)) {
     9753                return;
     9754            }
     9755
     9756            $state_updated = statpl_db_query(
     9757                statpl_db_prepare(
     9758                    "UPDATE {$this->view_import_state_table}
     9759                     SET status = 'running', updated_at = %s
     9760                     WHERE source = %s AND status IN ('pending', 'running')
     9761                       AND CONNECTION_ID() = %s
     9762                       AND IS_USED_LOCK(%s) = CONNECTION_ID()",
     9763                    current_time('mysql'),
     9764                    'slimstat',
     9765                    (string) $slimstat_lock['connection_id'],
     9766                    (string) $slimstat_lock['lock_name']
     9767                )
     9768            );
     9769
     9770            if (false === $state_updated) {
     9771                $this->schedule_slimstat_view_import_batch(60);
     9772                return;
     9773            }
     9774
     9775            // Retry previously failed rows independently of the sequential source batch.
     9776            if (!$this->slimstat_import_lock_is_current($slimstat_lock)) {
     9777                $this->schedule_slimstat_view_import_batch(60);
     9778                return;
     9779            }
     9780            if (!$this->process_slimstat_view_import_retries($slimstat_lock)) {
     9781                $this->schedule_slimstat_view_import_batch(60);
     9782                return;
     9783            }
     9784            if (!$this->slimstat_import_lock_is_current($slimstat_lock)) {
     9785                // A retry may have committed its event before a transparent DB
     9786                // reconnect. Its durable event marker makes replay safe, but this
     9787                // worker must not advance the sequential checkpoint on a new session.
     9788                $this->schedule_slimstat_view_import_batch(60);
     9789                return;
     9790            }
     9791
     9792            $checkpoint = (int) $state['last_processed_id'];
     9793            $snapshot_max_id = (int) $state['snapshot_max_id'];
     9794
     9795            $batch = $this->get_slimstat_view_import_batch($checkpoint, 50);
     9796
     9797            if (
     9798                !is_array($batch)
     9799                || !isset($batch['status'], $batch['rows'])
     9800                || 'available' !== $batch['status']
     9801                || !is_array($batch['rows'])
     9802            ) {
     9803                $this->schedule_slimstat_view_import_batch(60);
     9804                return;
     9805            }
     9806
     9807            foreach ($batch['rows'] as $row) {
     9808                if (!$this->slimstat_import_lock_is_current($slimstat_lock)) {
     9809                    $this->schedule_slimstat_view_import_batch(60);
     9810                    return;
     9811                }
     9812                // A checkpoint replay must not consume another import attempt.
     9813                $existing_failure = statpl_db_get_row(
     9814                    statpl_db_prepare(
     9815                        "SELECT product_id, status
     9816                         FROM {$this->view_import_failures_table}
     9817                         WHERE source = %s AND source_view_id = %d",
     9818                        'slimstat',
     9819                        (int) $row['id']
     9820                    ),
     9821                    ARRAY_A
     9822                );
     9823
     9824                if (!empty($wpdb->last_error)) {
     9825                    $this->schedule_slimstat_view_import_batch(60);
     9826                    return;
     9827                }
     9828
     9829                if (is_array($existing_failure)) {
     9830                    if ((int) $existing_failure['product_id'] !== (int) $row['product_id']) {
     9831                        $this->debug_log(
     9832                            'Statora Plus: SlimStat failure ledger product ID mismatch for source event '
     9833                            . (int) $row['id']
     9834                        );
     9835                        $this->schedule_slimstat_view_import_batch(60);
     9836                        return;
     9837                    }
     9838
     9839                    if (!in_array($existing_failure['status'], ['retry', 'failed'], true)) {
     9840                        $this->schedule_slimstat_view_import_batch(60);
     9841                        return;
     9842                    }
     9843
     9844                    // The failure is already durable; wait for its independent retry.
     9845                    $next_id = (int) $row['id'];
     9846
     9847                    if (!$this->slimstat_import_lock_is_current($slimstat_lock)
     9848                        || !$this->update_slimstat_view_import_checkpoint($checkpoint, $next_id, $slimstat_lock)) {
     9849                        $this->schedule_slimstat_view_import_batch(60);
     9850                        return;
     9851                    }
     9852
     9853                    $checkpoint = $next_id;
     9854                    continue;
     9855                }
     9856
     9857                $result = $this->import_slimstat_view_event($row);
     9858
     9859                if (!$this->slimstat_import_lock_is_current($slimstat_lock)) {
     9860                    // The event helper may have completed its atomic metric update
     9861                    // just before a connection loss. Do not move the checkpoint;
     9862                    // replay is protected by counts_applied and the unique event key.
     9863                    $this->schedule_slimstat_view_import_batch(60);
     9864                    return;
     9865                }
     9866
     9867                if (in_array($result, ['imported', 'duplicate'], true)) {
     9868                    $this->save_historical_import_report_record('slimstat', 'SlimStat', 'slimstat', hash('sha256', "slimstat\0" . (int) $row['id']), 'imported', is_array($existing_failure) ? (int) ($existing_failure['attempts'] ?? 0) : 0, '');
     9869                    // A previous retry record is no longer needed after confirmed success.
     9870                    $deleted = statpl_db_delete(
     9871                        $this->view_import_failures_table,
     9872                        [
     9873                            'source'        => 'slimstat',
     9874                            'source_view_id' => (int) $row['id'],
     9875                        ],
     9876                        ['%s', '%d']
     9877                    );
     9878
     9879                    if (false === $deleted) {
     9880                        $this->schedule_slimstat_view_import_batch(60);
     9881                        return;
     9882                    }
     9883                } else {
     9884                    // Capture the helper's SQL error before executing another database query.
     9885                    $error = !empty($wpdb->last_error)
     9886                        ? $wpdb->last_error
     9887                        : 'SlimStat historical view could not be imported.';
     9888
     9889                    if (!$this->record_slimstat_view_import_failure($row, $error)) {
     9890                        $this->schedule_slimstat_view_import_batch(60);
     9891                        return;
     9892                    }
     9893                }
     9894
     9895                $next_id = (int) $row['id'];
     9896
     9897                if (!$this->slimstat_import_lock_is_current($slimstat_lock)
     9898                    || !$this->update_slimstat_view_import_checkpoint($checkpoint, $next_id, $slimstat_lock)) {
     9899                    $this->schedule_slimstat_view_import_batch(60);
     9900                    return;
     9901                }
     9902
     9903                $checkpoint = $next_id;
     9904            }
     9905
     9906            if (!empty($batch['rows'])) {
     9907                $this->schedule_slimstat_view_import_batch(5);
     9908                return;
     9909            }
     9910
     9911            // An empty successful source query means the snapshot has been exhausted.
     9912            if (!$this->slimstat_import_lock_is_current($slimstat_lock)) {
     9913                $this->schedule_slimstat_view_import_batch(60);
     9914                return;
     9915            }
     9916            if ($checkpoint < $snapshot_max_id) {
     9917                if (!$this->update_slimstat_view_import_checkpoint($checkpoint, $snapshot_max_id, $slimstat_lock)) {
     9918                    $this->schedule_slimstat_view_import_batch(60);
     9919                    return;
     9920                }
     9921
     9922                $checkpoint = $snapshot_max_id;
     9923            }
     9924
     9925            $failure_counts = statpl_db_get_row(
     9926                statpl_db_prepare(
     9927                    "SELECT
     9928                        SUM(CASE WHEN status = 'retry' THEN 1 ELSE 0 END) AS pending_retries,
     9929                        SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS terminal_failures
     9930                     FROM {$this->view_import_failures_table}
     9931                     WHERE source = %s",
     9932                    'slimstat'
     9933                ),
     9934                ARRAY_A
     9935            );
     9936
     9937            if (!empty($wpdb->last_error) || !is_array($failure_counts)) {
     9938                $this->schedule_slimstat_view_import_batch(60);
     9939                return;
     9940            }
     9941
     9942            $pending_retries = (int) $failure_counts['pending_retries'];
     9943            $terminal_failures = (int) $failure_counts['terminal_failures'];
     9944
     9945            if ($pending_retries > 0) {
     9946                $this->schedule_slimstat_view_import_batch(
     9947                    $this->get_slimstat_view_import_retry_delay()
     9948                );
     9949                return;
     9950            }
     9951
     9952            if (!$this->slimstat_import_lock_is_current($slimstat_lock)) {
     9953                $this->schedule_slimstat_view_import_batch(60);
     9954                return;
     9955            }
     9956            $final_status = $terminal_failures > 0 ? 'error' : 'complete';
     9957
     9958            $finalized = statpl_db_query(
     9959                statpl_db_prepare(
     9960                    "UPDATE {$this->view_import_state_table}
     9961                     SET status = %s, updated_at = %s
     9962                     WHERE source = %s AND status IN ('pending', 'running')
     9963                       AND CONNECTION_ID() = %s
     9964                       AND IS_USED_LOCK(%s) = CONNECTION_ID()",
     9965                    $final_status,
     9966                    current_time('mysql'),
     9967                    'slimstat',
     9968                    (string) $slimstat_lock['connection_id'],
     9969                    (string) $slimstat_lock['lock_name']
     9970                )
     9971            );
     9972
     9973            if (1 !== $finalized || !$this->slimstat_import_lock_is_current($slimstat_lock)) {
     9974                $this->schedule_slimstat_view_import_batch(60);
     9975            }
     9976        } finally {
     9977            // Release only through the original connection. If WordPress transparently
     9978            // reconnected, MySQL has already released the old session's advisory lock.
     9979            $this->release_slimstat_import_lock($slimstat_lock);
     9980        }
     9981    }
     9982
     9983    /** Schedule one independent SlimStat view import batch. */
     9984    public function schedule_slimstat_view_import_batch($delay = 10) {
     9985        global $wpdb;
     9986
     9987        $status = statpl_db_get_var(
     9988            statpl_db_prepare(
     9989                "SELECT status FROM {$this->view_import_state_table} WHERE source = %s",
     9990                'slimstat'
     9991            )
     9992        );
     9993
     9994        if (!empty($wpdb->last_error) || !in_array($status, ['pending', 'running'], true)) {
     9995            return;
     9996        }
     9997
     9998        $delay = max(5, (int) $delay);
     9999
     10000        if (function_exists('as_schedule_single_action') && !did_action('action_scheduler_init')) {
     10001            add_action('action_scheduler_init', [$this, 'schedule_slimstat_view_import_batch'], 10, 0);
     10002            return;
     10003        }
     10004
     10005        if (
     10006            did_action('action_scheduler_init')
     10007            && class_exists('ActionScheduler')
     10008            && class_exists('ActionScheduler_Store')
     10009            && null !== ActionScheduler::store()->query_action(
     10010                [
     10011                    'hook'    => 'statpl_view_import_batch',
     10012                    'status'  => ActionScheduler_Store::STATUS_PENDING,
     10013                    'group'   => 'statpl-plus',
     10014                    'args'    => [],
     10015                    'orderby' => 'none',
     10016                ]
     10017            )
     10018        ) {
     10019            return;
     10020        }
     10021
     10022        if (function_exists('as_schedule_single_action') && did_action('action_scheduler_init')) {
     10023            $action_id = as_schedule_single_action(
     10024                time() + $delay,
     10025                'statpl_view_import_batch',
     10026                [],
     10027                'statpl-plus'
     10028            );
     10029
     10030            if ($action_id) {
     10031                return;
     10032            }
     10033        }
     10034
     10035        if (!wp_next_scheduled('statpl_view_import_batch')) {
     10036            wp_schedule_single_event(time() + $delay, 'statpl_view_import_batch');
     10037        }
     10038    }
     10039
    58010040    /** Schedule one import batch with a delay to avoid overlapping batch execution. */
    58110041    public function schedule_import_batch($delay = 10) {
    … …  
    64510105        $args = array_merge([$since,$since,$since], $ids);
    64610106        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query is prepared with all variable values; table name is a class property built from the WordPress table prefix and fixed suffix; event counts must be current.
    647         $rows = $wpdb->get_results($wpdb->prepare($sql, $args), ARRAY_A);
     10107        $rows = statpl_db_get_results(statpl_db_prepare($sql, $args), ARRAY_A);
    64810108        foreach ($rows as $r) {
    64910109            $id = (int)$r['product_id']; if (!isset($out[$id])) continue;
    … …  
    66210122        $event_args = array_merge([$event_since, $event_since], $ids);
    66310123        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query is prepared with all variable values; table name is a class property built from the WordPress table prefix and fixed suffix; event counts must be current.
    664         $event_rows = $wpdb->get_results($wpdb->prepare($event_sql, $event_args), ARRAY_A);
     10124        $event_rows = statpl_db_get_results(statpl_db_prepare($event_sql, $event_args), ARRAY_A);
    66510125        foreach ($event_rows as $event_row) {
    66610126            $id = (int)$event_row['product_id'];
    … …  
    68610146        $summary = [
    68710147            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This dashboard aggregate is cached by global_summary_metrics() for 60 seconds and invalidated when relevant data changes.
    688             'products' => (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type='product' AND post_status IN ('publish','private','draft','pending')"),
     10148            'products' => (int) statpl_db_get_var("SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type='product' AND post_status IN ('publish','private','draft','pending')"),
    68910149            'views' => 0, 'views24' => 0, 'orders' => 0, 'orders24' => 0,
    69010150            'units' => 0.0, 'units24' => 0.0, 'revenue' => 0.0, 'revenue24' => 0.0,
    … …  
    69410154        // All product views are recorded and aggregated by Statora Plus.
    69510155        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is constructor-generated from the WordPress prefix and a fixed suffix; the dashboard aggregate is cached for 60 seconds and view recording invalidates that cache.
    696         $summary['views'] = (int) $wpdb->get_var("SELECT COALESCE(SUM(total_views),0) FROM {$this->views_table}");
     10156        $summary['views'] = (int) statpl_db_get_var("SELECT COALESCE(SUM(total_views),0) FROM {$this->views_table}");
    69710157        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; the date value is prepared.
    698         $summary['views24'] = (int) $wpdb->get_var(
    699             $wpdb->prepare(
     10158        $summary['views24'] = (int) statpl_db_get_var(
     10159            statpl_db_prepare(
    70010160                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; the date value is prepared.
    70110161                "SELECT COUNT(*) FROM {$this->events_table} WHERE viewed_at >= %s",
    … …  
    70710167        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL identifiers cannot be bound as value placeholders; the identifier is internal, not user-controlled.
    70810168        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This aggregate is cached by global_summary_metrics() for 60 seconds and invalidated on relevant changes.
    709         $sales = $wpdb->get_row($wpdb->prepare("SELECT
     10169        $sales = statpl_db_get_row(statpl_db_prepare("SELECT
    71010170            COALESCE(SUM(CASE WHEN statpl_snapshot.order_status='completed' AND statpl_snapshot.completed_at IS NOT NULL THEN statpl_snapshot.net_quantity ELSE 0 END),0) AS units_total,
    71110171            COALESCE(SUM(CASE WHEN statpl_snapshot.order_status='completed' AND statpl_snapshot.completed_at >= %s THEN statpl_snapshot.net_quantity ELSE 0 END),0) AS units24,
    … …  
    72310183        // Product-level detail is calculated separately from rows with actual product IDs.
    72410184        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed suffix; no user-supplied SQL values are used.
    725         $event_totals = $wpdb->get_row(
     10185        $event_totals = statpl_db_get_row(
    72610186            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- The table identifier is constructor-generated from $wpdb->prefix and a fixed plugin suffix; this aggregate has no user-supplied SQL values.
    72710187            "SELECT COUNT(DISTINCT CASE WHEN event_type='cancelled' AND product_id=0 THEN order_id END) AS cancelled_total, COUNT(DISTINCT CASE WHEN event_type='refund' AND product_id=0 AND event_ref_id > 0 THEN event_ref_id END) AS refunds_total FROM {$this->order_events_table}",
    … …  
    73410194        // Every normal order is represented once per product in the snapshot; count order IDs only once.
    73510195        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is constructor-generated from the WordPress prefix and a fixed suffix.; Table identifier is internally generated from the WordPress prefix and a fixed suffix.; This aggregate is part of the 60-second cached dashboard summary.
    736         $summary['total_orders'] = (int) $wpdb->get_var("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table}");
     10196        $summary['total_orders'] = (int) statpl_db_get_var("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table}");
    73710197        // Clean orders are currently processing/completed and retain some non-refunded product quantity/value.
    73810198        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is constructor-generated from the WordPress prefix and a fixed suffix.; Table identifier is internally generated from the WordPress prefix and a fixed suffix.; This aggregate is part of the 60-second cached dashboard summary.
    739         $summary['orders'] = (int) $wpdb->get_var("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table} WHERE order_status IN ('processing','completed') AND (net_quantity > 0 OR net_revenue > 0)");
     10199        $summary['orders'] = (int) statpl_db_get_var("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table} WHERE order_status IN ('processing','completed') AND (net_quantity > 0 OR net_revenue > 0)");
    74010200        // Today's clean orders are based on order creation date, not payment/completion date.
    74110201        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is internally generated; date value is prepared.; This aggregate is part of the 60-second cached dashboard summary.
    742         $summary['orders24'] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table} WHERE order_status IN ('processing','completed') AND created_at >= %s AND (net_quantity > 0 OR net_revenue > 0)", $since));
     10202        $summary['orders24'] = (int) statpl_db_get_var(statpl_db_prepare("SELECT COUNT(DISTINCT order_id) FROM {$this->order_items_table} WHERE order_status IN ('processing','completed') AND created_at >= %s AND (net_quantity > 0 OR net_revenue > 0)", $since));
    74310203        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is internally generated; date value is prepared.; This aggregate is part of the 60-second cached dashboard summary.
    744         $summary['cancelled24'] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT order_id) FROM {$this->order_events_table} WHERE product_id=0 AND event_type='cancelled' AND event_at >= %s", $since));
     10204        $summary['cancelled24'] = (int) statpl_db_get_var(statpl_db_prepare("SELECT COUNT(DISTINCT order_id) FROM {$this->order_events_table} WHERE product_id=0 AND event_type='cancelled' AND event_at >= %s", $since));
    74510205        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table identifier is internally generated; date value is prepared.; This aggregate is part of the 60-second cached dashboard summary.
    746         $summary['refunds24'] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT event_ref_id) FROM {$this->order_events_table} WHERE product_id=0 AND event_type='refund' AND event_ref_id > 0 AND event_at >= %s", $since));
     10206        $summary['refunds24'] = (int) statpl_db_get_var(statpl_db_prepare("SELECT COUNT(DISTINCT event_ref_id) FROM {$this->order_events_table} WHERE product_id=0 AND event_type='refund' AND event_ref_id > 0 AND event_at >= %s", $since));
    74710207        $summary['revenue'] = round($summary['revenue'], wc_get_price_decimals());
    74810208        $summary['revenue24'] = round($summary['revenue24'], wc_get_price_decimals());
    … …  
    82910289                        SELECT product_id, COUNT(*) AS views_today
    83010290                        FROM {$this->events_table}
    831                         WHERE viewed_at >= " . $wpdb->prepare('%s', $since_mysql) . "
     10291                        WHERE viewed_at >= " . statpl_db_prepare('%s', $since_mysql) . "
    83210292                        GROUP BY product_id
    83310293                    ) recent ON recent.product_id = totals.product_id
    … …  
    83710297                    SELECT product_id, COUNT(*) AS views_today
    83810298                    FROM {$this->events_table}
    839                     WHERE viewed_at >= " . $wpdb->prepare('%s', $since_mysql) . "
     10299                    WHERE viewed_at >= " . statpl_db_prepare('%s', $since_mysql) . "
    84010300                    GROUP BY product_id
    84110301                ) statpl_recent_views ON statpl_recent_views.product_id = p.ID";
    … …  
    84910309                    SELECT product_id,
    85010310                        COUNT(DISTINCT CASE WHEN order_status IN ('processing','completed') AND (net_quantity > 0 OR net_revenue > 0) THEN order_id END) AS orders,
    851                         COUNT(DISTINCT CASE WHEN order_status IN ('processing','completed') AND created_at >= " . $wpdb->prepare('%s', $since) . " AND (net_quantity > 0 OR net_revenue > 0) THEN order_id END) AS orders24,
     10311                        COUNT(DISTINCT CASE WHEN order_status IN ('processing','completed') AND created_at >= " . statpl_db_prepare('%s', $since) . " AND (net_quantity > 0 OR net_revenue > 0) THEN order_id END) AS orders24,
    85210312                        SUM(CASE WHEN order_status='completed' AND completed_at IS NOT NULL THEN net_quantity ELSE 0 END) AS units,
    853                         SUM(CASE WHEN order_status='completed' AND completed_at >= " . $wpdb->prepare('%s', $since) . " THEN net_quantity ELSE 0 END) AS units24,
     10313                        SUM(CASE WHEN order_status='completed' AND completed_at >= " . statpl_db_prepare('%s', $since) . " THEN net_quantity ELSE 0 END) AS units24,
    85410314                        SUM(CASE WHEN order_status='completed' AND completed_at IS NOT NULL THEN net_revenue ELSE 0 END) AS revenue,
    855                         SUM(CASE WHEN order_status='completed' AND completed_at >= " . $wpdb->prepare('%s', $since) . " THEN net_revenue ELSE 0 END) AS revenue24
     10315                        SUM(CASE WHEN order_status='completed' AND completed_at >= " . statpl_db_prepare('%s', $since) . " THEN net_revenue ELSE 0 END) AS revenue24
    85610316                    FROM {$this->order_items_table} GROUP BY product_id
    85710317                ) statpl_orders ON statpl_orders.product_id = p.ID
    … …  
    85910319                    SELECT product_id,
    86010320                        COUNT(DISTINCT CASE WHEN event_type='cancelled' THEN order_id END) AS cancelled,
    861                         COUNT(DISTINCT CASE WHEN event_type='cancelled' AND event_at >= " . $wpdb->prepare('%s', $since) . " THEN order_id END) AS cancelled24,
     10321                        COUNT(DISTINCT CASE WHEN event_type='cancelled' AND event_at >= " . statpl_db_prepare('%s', $since) . " THEN order_id END) AS cancelled24,
    86210322                        COUNT(DISTINCT CASE WHEN event_type='refund' AND event_ref_id > 0 THEN event_ref_id END) AS refunds,
    863                         COUNT(DISTINCT CASE WHEN event_type='refund' AND event_ref_id > 0 AND event_at >= " . $wpdb->prepare('%s', $since) . " THEN event_ref_id END) AS refunds24
     10323                        COUNT(DISTINCT CASE WHEN event_type='refund' AND event_ref_id > 0 AND event_at >= " . statpl_db_prepare('%s', $since) . " THEN event_ref_id END) AS refunds24
    86410324                    FROM {$this->order_events_table} GROUP BY product_id
    86510325                ) statpl_events ON statpl_events.product_id = p.ID";
    … …  
    86910329            // $join and $where are assembled only from fixed SQL fragments and prepared placeholders; user input is passed in $args.
    87010330            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query fragments are plugin-controlled; all user-supplied values are bound through prepare(); product filters are evaluated live.
    871             $total = (int) $wpdb->get_var($wpdb->prepare($count_sql, $args));
     10331            $total = (int) statpl_db_get_var(statpl_db_prepare($count_sql, $args));
    87210332            $order_column = $order_columns[$orderby] ?? 'p.post_title';
    87310333            $pages_for_query = max(1, (int) ceil($total / $per_page));
    … …  
    87810338            // $order_column is selected from the internal allowlist and $order is normalized before query construction.
    87910339            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query fragments are plugin-controlled; all user-supplied values are bound through prepare(); product filters are evaluated live.
    880             $product_ids = array_map('intval', $wpdb->get_col($wpdb->prepare($id_sql, $id_args)));
     10340            $product_ids = array_map('intval', statpl_db_get_col(statpl_db_prepare($id_sql, $id_args)));
    88110341        } else {
    88210342            $base_args = ['post_type'=>'product','post_status'=>$statuses,'posts_per_page'=>-1,'fields'=>'ids','orderby'=>'ID','order'=>'ASC'];
    … …  
    89110351if (false === $sku_ids) {
    89210352    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Prepared partial-SKU lookup is required; results are cached for 60 seconds.
    893     $sku_ids = $wpdb->get_col(
    894         $wpdb->prepare(
     10353    $sku_ids = statpl_db_get_col(
     10354        statpl_db_prepare(
    89510355    "SELECT DISTINCT p.ID
    89610356    FROM {$wpdb->posts} p
    … …  
    92810388            $st = $stats_by_product[$product_id] ?? ['orders'=>0,'orders24'=>0,'units'=>0,'units24'=>0,'revenue'=>0.0,'revenue24'=>0.0,'cancelled'=>0,'cancelled24'=>0,'refunds'=>0,'refunds24'=>0];
    92910389            $stock_summary = $this->stock_summary_for_product($product);
     10390            $product_status = get_post_status($product_id);
     10391            $visibility_labels = [
     10392                'private' => 'Κρυφό',
     10393                'draft' => 'Πρόχειρο',
     10394                'pending' => 'Σε αναμονή',
     10395            ];
    93010396            $rows[] = ['id'=>$product_id,'title'=>$product->get_name(),'date'=>get_post_field('post_date',$product_id),'sku'=>$product->get_sku(),
    931                 'views'=>(int)$views['total'],'views24'=>(int)$views['today'],'source'=>$views['source'],'stock_managed'=>$stock_summary['managed'],'stock'=>$stock_summary['quantity'],'edit_url'=>get_edit_post_link($product_id, 'raw')] + $st;
     10397                'product_visibility'=>$visibility_labels[$product_status] ?? '',
     10398                'views'=>(int)$views['total'],'views24'=>(int)$views['today'],'source'=>$views['source'],'stock_managed'=>$stock_summary['managed'],'stock'=>$stock_summary['quantity'],'stock_in_stock'=>$stock_summary['in_stock'],'edit_url'=>get_edit_post_link($product_id, 'raw')] + $st;
    93210399        }
    93310400        if (!$db_paged) {
    … …  
    94510412        $summary = $this->global_summary_metrics();
    94610413
    947         echo '<div class="wrap statpl-metrics-wrap"><h1>Statora Plus</h1><p class="description">Στατιστικά προϊόντων WooCommerce σε μία συνοπτική εικόνα.</p>';
     10414        echo '<div class="wrap statpl-metrics-wrap"><h1>Statora Plus</h1>';
    94810415        $overview_url = add_query_arg(['page'=>'statpl-product-metrics','view'=>'overview'], admin_url('admin.php'));
    94910416        $products_url = add_query_arg(['page'=>'statpl-product-metrics','view'=>'products'], admin_url('admin.php'));
    95010417        echo '<nav class="nav-tab-wrapper"><a class="nav-tab '.($view === 'overview' ? 'nav-tab-active' : '').'" href="'.esc_url($overview_url).'">Συνολική εικόνα</a><a class="nav-tab '.($view === 'products' ? 'nav-tab-active' : '').'" href="'.esc_url($products_url).'">Στατιστικά ανά προϊόν</a></nav>';
    95110418
    952         if (get_option('statpl_import_complete')) {
    953             echo '<div class="notice notice-success inline"><p>Η αρχική εισαγωγή ιστορικού έχει επισημανθεί ως ολοκληρωμένη.</p></div>';
    954         } else {
    955             $import_page = max(1, (int) get_option('statpl_import_page', 1));
    956             echo '<div class="notice notice-info inline"><p>Η αρχική εισαγωγή ιστορικού εκτελείται στο παρασκήνιο. Επεξεργασία παρτίδας: '.esc_html((string)$import_page).'. Τα στατιστικά ενδέχεται να είναι ελλιπή μέχρι να ολοκληρωθεί.</p></div>';
    957         }
     10419        $this->render_historical_import_notice();
    95810420        if ($view === 'overview') {
    95910421            echo '<h2>Συνολικά στατιστικά καταστήματος</h2>';
    … …  
    100610468        if (!$visible_rows) echo '<tr><td colspan="14">Δεν βρέθηκαν προϊόντα.</td></tr>';
    100710469        foreach ($visible_rows as $r) {
    1008             echo '<tr><td><a class="statpl-product-title" href="'.esc_url($r['edit_url']).'">'.esc_html($r['title']).'</a></td>';
    1009             echo '<td>'.($r['stock_managed'] ? esc_html(number_format_i18n($r['stock'])) : '<span class="statpl-stock-not-tracked">Δεν παρακολουθείται</span>').'</td>';
     10470            echo '<tr><td><a class="statpl-product-title" href="'.esc_url($r['edit_url']).'">'.esc_html($r['title']).'</a>';
     10471            if (!empty($r['product_visibility'])) {
     10472                echo ' <span class="statpl-product-visibility">'.esc_html($r['product_visibility']).'</span>';
     10473            }
     10474            echo '</td>';
     10475            if ($r['stock_managed']) {
     10476                $stock_quantity = (float) $r['stock'];
     10477                if ($stock_quantity > 0) {
     10478                    echo '<td>'.esc_html(number_format_i18n($stock_quantity)).'</td>';
     10479                } elseif (!empty($r['stock_in_stock'])) {
     10480                    // Zero quantity with backorders allowed remains purchasable.
     10481                    echo '<td>0</td>';
     10482                } else {
     10483                    echo '<td>Εξαντλημένο</td>';
     10484                }
     10485            } else {
     10486                echo '<td><span class="statpl-stock-not-tracked">Δεν γίνεται διαχείριση αποθέματος</span></td>';
     10487            }
    101010488            echo '<td>'.esc_html(number_format_i18n($r['views'])).'</td><td>'.esc_html(number_format_i18n($r['views24'])).'</td>';
    101110489            foreach (['orders','orders24'] as $k) echo '<td>'.esc_html(number_format_i18n($r[$k])).'</td>';
    … …  
    102210500}
    102310501STATPL_Core::instance();
     10502
     10503
     10504/**
     10505 * Page View Count adapter.
     10506 *
     10507 * Reads only the provider's immutable, per-product daily rows from pvc_daily.
     10508 * The current day is intentionally excluded because Page View Count updates
     10509 * that row in place; importing a mutable count would violate the immutable
     10510 * source snapshot contract. Lifetime totals in pvc_total are deliberately not
     10511 * imported because they overlap the daily rows and have no date boundaries.
     10512 */
     10513if (!class_exists('STATPL_Page_View_Count_Historical_Adapter')) {
     10514    final class STATPL_Page_View_Count_Historical_Adapter implements STATPL_Historical_View_Adapter {
     10515        const ADAPTER_KEY = 'page_view_count';
     10516        const SOURCE_KEY = 'page_view_count';
     10517        const ADAPTER_VERSION = '1.0.0';
     10518        const ORIGIN_KEY = 'daily_product_views';
     10519
     10520        public function get_adapter_key() {
     10521            return self::ADAPTER_KEY;
     10522        }
     10523
     10524        public function get_source_key() {
     10525            return self::SOURCE_KEY;
     10526        }
     10527
     10528        public function get_display_name() {
     10529            return 'Page Views Count';
     10530        }
     10531
     10532        public function get_adapter_version() {
     10533            return self::ADAPTER_VERSION;
     10534        }
     10535
     10536        private function table_name() {
     10537            global $wpdb;
     10538            return $wpdb->prefix . 'pvc_daily';
     10539        }
     10540
     10541        private function quoted_table_name() {
     10542            $table = $this->table_name();
     10543            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     10544                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     10545            }
     10546            return '`' . $table . '`';
     10547        }
     10548
     10549        private function inspect_table() {
     10550            global $wpdb;
     10551            $table = $this->table_name();
     10552            // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- SHOW TABLES LIKE has one %s placeholder and receives exactly one escaped table-name value.
     10553            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     10554            if (!empty($wpdb->last_error)) {
     10555                return ['status' => 'database_error', 'error' => (string) $wpdb->last_error];
     10556            }
     10557            if ($exists !== $table) {
     10558                return ['status' => 'missing_table'];
     10559            }
     10560
     10561            // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- SHOW COLUMNS contains no value placeholders; the table identifier is validated and quoted by quoted_table_name().
     10562// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     10563            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A);
     10564            if (!empty($wpdb->last_error) || !is_array($columns)) {
     10565                return ['status' => 'database_error', 'error' => (string) $wpdb->last_error];
     10566            }
     10567            $names = [];
     10568            foreach ($columns as $column) {
     10569                if (isset($column['Field'])) {
     10570                    $names[] = (string) $column['Field'];
     10571                }
     10572            }
     10573            $required = ['id', 'time', 'postnum', 'postcount'];
     10574            if (array_diff($required, $names)) {
     10575                return ['status' => 'incompatible_schema'];
     10576            }
     10577            return ['status' => 'available'];
     10578        }
     10579
     10580        public function should_register_adapter() {
     10581            global $wpdb;
     10582            $inspection = $this->inspect_table();
     10583            if ($inspection['status'] !== 'missing_table') { return true; }
     10584            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     10585            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     10586                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     10587            }
     10588            $registry_table = '`' . $registry_name . '`';
     10589            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     10590            if (!empty($wpdb->last_error)) { return true; }
     10591            if ($registry_exists !== $registry_name) { return false; }
     10592            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     10593// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     10594            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     10595            return !empty($wpdb->last_error) || !empty($source_id);
     10596        }
     10597
     10598        public function discover_origins() {
     10599            $inspection = $this->inspect_table();
     10600            if ($inspection['status'] === 'missing_table') {
     10601                // A previously registered source can be marked unavailable by the
     10602                // core's authoritative inventory reconciliation if its table was
     10603                // removed. A never-installed source is not registered needlessly.
     10604                return [];
     10605            }
     10606            return [[
     10607                'origin_key' => self::ORIGIN_KEY,
     10608                'capabilities' => [
     10609                    'product_views' => true,
     10610                    'granularity' => 'daily_aggregate',
     10611                    'immutable_past_days' => true,
     10612                ],
     10613                'detection_status' => $inspection['status'],
     10614            ]];
     10615        }
     10616
     10617        public function get_origin_snapshot($origin_key) {
     10618            global $wpdb;
     10619            if ($origin_key !== self::ORIGIN_KEY) {
     10620                return [];
     10621            }
     10622            $inspection = $this->inspect_table();
     10623            if ($inspection['status'] !== 'available') {
     10624                throw new RuntimeException('Page View Count source table is not available or has an incompatible schema.');
     10625            }
     10626
     10627            $through_date = current_datetime()->modify('-1 day')->format('Y-m-d');
     10628            $posts_table_name = $wpdb->posts;
     10629            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     10630                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     10631            }
     10632            $posts_table = '`' . $posts_table_name . '`';
     10633// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     10634            $max_id = statpl_db_get_var(statpl_db_prepare(
     10635                'SELECT MAX(d.id)
     10636                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS d
     10637                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p
     10638                   ON p.ID = CAST(d.postnum AS UNSIGNED)
     10639                  AND p.post_type = %s
     10640                 WHERE d.postnum REGEXP %s AND d.time <= %s',
     10641                'product',
     10642                '^[0-9]+$',
     10643                $through_date
     10644            ));
     10645            if (!empty($wpdb->last_error)) {
     10646                throw new RuntimeException('Page View Count snapshot query failed.');
     10647            }
     10648
     10649            return [
     10650                'boundary' => [
     10651                    'max_id' => max(0, (int) $max_id),
     10652                    'through_date' => $through_date,
     10653                ],
     10654                'initial_checkpoint' => null,
     10655                'captured_at' => current_time('mysql'),
     10656            ];
     10657        }
     10658
     10659        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     10660            global $wpdb;
     10661            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     10662                throw new RuntimeException('Invalid Page View Count origin or snapshot.');
     10663            }
     10664            $inspection = $this->inspect_table();
     10665            if ($inspection['status'] !== 'available') {
     10666                throw new RuntimeException('Page View Count source table became unavailable or incompatible.');
     10667            }
     10668
     10669            $boundary = $snapshot['boundary'];
     10670            $max_id = isset($boundary['max_id']) ? absint($boundary['max_id']) : -1;
     10671            $through_date = isset($boundary['through_date']) ? (string) $boundary['through_date'] : '';
     10672            $date = DateTime::createFromFormat('!Y-m-d', $through_date);
     10673            if ($max_id < 0 || !$date || $date->format('Y-m-d') !== $through_date) {
     10674                throw new RuntimeException('Invalid Page View Count snapshot boundary.');
     10675            }
     10676
     10677            $cursor_id = 0;
     10678            if (is_array($checkpoint) && isset($checkpoint['cursor'])) {
     10679                $cursor_value = $checkpoint['cursor'];
     10680                if ($cursor_value !== null) {
     10681                    $cursor_id = absint($cursor_value);
     10682                }
     10683            } elseif (is_numeric($checkpoint)) {
     10684                $cursor_id = absint($checkpoint);
     10685            }
     10686
     10687            $limit = max(1, min(100, absint($limit)));
     10688            $posts_table_name = $wpdb->posts;
     10689            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     10690                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     10691            }
     10692            $posts_table = '`' . $posts_table_name . '`';
     10693// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     10694            $rows = statpl_db_get_results(statpl_db_prepare(
     10695                'SELECT MAX(d.id) AS id, d.time, d.postnum, SUM(d.postcount) AS postcount
     10696                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS d
     10697                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p
     10698                   ON p.ID = CAST(d.postnum AS UNSIGNED)
     10699                  AND p.post_type = %s
     10700                 WHERE d.postnum REGEXP %s
     10701                   AND d.id <= %d
     10702                   AND d.time <= %s
     10703                 GROUP BY d.time, d.postnum
     10704                 HAVING MAX(d.id) > %d
     10705                 ORDER BY MAX(d.id) ASC
     10706                 LIMIT %d',
     10707                'product',
     10708                '^[0-9]+$',
     10709                $max_id,
     10710                $through_date,
     10711                $cursor_id,
     10712                $limit + 1
     10713            ), ARRAY_A);
     10714            if (!empty($wpdb->last_error) || !is_array($rows)) {
     10715                throw new RuntimeException('Page View Count batch query failed.');
     10716            }
     10717
     10718            $has_more = count($rows) > $limit;
     10719            if ($has_more) {
     10720                $rows = array_slice($rows, 0, $limit);
     10721            }
     10722            $next_checkpoint = $cursor_id;
     10723            if ($rows) {
     10724                $last = end($rows);
     10725                $next_checkpoint = absint($last['id']);
     10726            }
     10727
     10728            return [
     10729                'rows' => array_values($rows),
     10730                'has_more' => $has_more,
     10731                'next_checkpoint' => $next_checkpoint,
     10732                'boundary_applied' => true,
     10733            ];
     10734        }
     10735
     10736        public function normalize_record($origin_key, $record) {
     10737            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) {
     10738                return null;
     10739            }
     10740            $record_id = isset($record['id']) ? absint($record['id']) : 0;
     10741            $product_raw = isset($record['postnum']) ? (string) $record['postnum'] : '';
     10742            $views_raw = $record['postcount'] ?? null;
     10743            $date = isset($record['time']) ? (string) $record['time'] : '';
     10744            if (!$record_id || !preg_match('/^[1-9][0-9]*$/D', $product_raw) || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) {
     10745                return null;
     10746            }
     10747            $parsed_date = DateTime::createFromFormat('!Y-m-d', $date);
     10748            $errors = DateTime::getLastErrors();
     10749            if (!$parsed_date || $parsed_date->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) {
     10750                return null;
     10751            }
     10752
     10753            $product_id = (int) $product_raw;
     10754            $views = (int) $views_raw;
     10755            $period_start = $date . ' 00:00:00';
     10756            $period_end = $date . ' 23:59:59';
     10757
     10758            return [
     10759                'product_id' => $product_id,
     10760                'source_record_key' => 'pvc_daily_' . $product_id . '_' . $date,
     10761                'views' => $views,
     10762                'record_type' => 'aggregate',
     10763                'period_start' => $period_start,
     10764                'period_end' => $period_end,
     10765                'cursor' => $record_id,
     10766            ];
     10767        }
     10768    }
     10769}
     10770
     10771add_filter('statpl_historical_view_adapters', static function ($adapters) {
     10772    if (!is_array($adapters)) {
     10773        return $adapters;
     10774    }
     10775    $adapter = new STATPL_Page_View_Count_Historical_Adapter();
     10776    if ($adapter->should_register_adapter()) { $adapters[] = $adapter; }
     10777    return $adapters;
     10778}, 30);
     10779
     10780/**
     10781 * WP Statistics adapter.
     10782 *
     10783 * Imports daily rows from statistics_pages where WP Statistics has explicitly
     10784 * classified the viewed resource as a WooCommerce product. The historical
     10785 * summary table is not read here: it no longer preserves a trustworthy date
     10786 * interval after detailed rows have been aggregated.
     10787 */
     10788if (!class_exists('STATPL_WP_Statistics_Historical_Adapter')) {
     10789    final class STATPL_WP_Statistics_Historical_Adapter implements STATPL_Historical_View_Adapter {
     10790        const ADAPTER_KEY = 'wp_statistics_pages';
     10791        const SOURCE_KEY = 'wp_statistics';
     10792        const ADAPTER_VERSION = '1.0.0';
     10793        const ORIGIN_KEY = 'product_daily_pages';
     10794
     10795        public function get_adapter_key() { return self::ADAPTER_KEY; }
     10796        public function get_source_key() { return self::SOURCE_KEY; }
     10797        public function get_display_name() { return 'WP Statistics'; }
     10798        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     10799
     10800        private function table_name() {
     10801            global $wpdb;
     10802            return $wpdb->prefix . 'statistics_pages';
     10803        }
     10804
     10805        private function quoted_table_name() {
     10806            $table = $this->table_name();
     10807            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     10808                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     10809            }
     10810            return '`' . $table . '`';
     10811        }
     10812
     10813        private function inspect_table() {
     10814            global $wpdb;
     10815            $table = $this->table_name();
     10816            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     10817            if (!empty($wpdb->last_error)) {
     10818                return ['status' => 'database_error', 'error' => (string) $wpdb->last_error];
     10819            }
     10820            if ($exists !== $table) {
     10821                return ['status' => 'missing_table'];
     10822            }
     10823// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     10824            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A);
     10825            if (!empty($wpdb->last_error) || !is_array($columns)) {
     10826                return ['status' => 'database_error', 'error' => (string) $wpdb->last_error];
     10827            }
     10828            $names = [];
     10829            foreach ($columns as $column) {
     10830                if (isset($column['Field'])) { $names[] = (string) $column['Field']; }
     10831            }
     10832            if (array_diff(['page_id', 'uri', 'type', 'date', 'count', 'id'], $names)) {
     10833                return ['status' => 'incompatible_schema'];
     10834            }
     10835            return ['status' => 'available'];
     10836        }
     10837
     10838        public function should_register_adapter() {
     10839            global $wpdb;
     10840            $inspection = $this->inspect_table();
     10841            if ($inspection['status'] !== 'missing_table') { return true; }
     10842            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     10843            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     10844                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     10845            }
     10846            $registry_table = '`' . $registry_name . '`';
     10847            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     10848            if (!empty($wpdb->last_error)) { return true; }
     10849            if ($registry_exists !== $registry_name) { return false; }
     10850            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     10851// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     10852            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     10853            return !empty($wpdb->last_error) || !empty($source_id);
     10854        }
     10855
     10856        public function discover_origins() {
     10857            $inspection = $this->inspect_table();
     10858            if ($inspection['status'] === 'missing_table') { return []; }
     10859            return [[
     10860                'origin_key' => self::ORIGIN_KEY,
     10861                'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'immutable_past_days' => true],
     10862                'detection_status' => $inspection['status'],
     10863            ]];
     10864        }
     10865
     10866        public function get_origin_snapshot($origin_key) {
     10867            global $wpdb;
     10868            if ($origin_key !== self::ORIGIN_KEY) { return []; }
     10869            if ($this->inspect_table()['status'] !== 'available') {
     10870                throw new RuntimeException('WP Statistics pages table is unavailable or incompatible.');
     10871            }
     10872            $through_date = current_datetime()->modify('-1 day')->format('Y-m-d');
     10873            $posts_table_name = $wpdb->posts;
     10874            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     10875                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     10876            }
     10877            $posts_table = '`' . $posts_table_name . '`';
     10878// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     10879            $max_id = statpl_db_get_var(statpl_db_prepare(
     10880                'SELECT MAX(s.page_id)
     10881                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s
     10882                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.id AND p.post_type = %s
     10883                 WHERE s.type = %s AND s.date <= %s',
     10884                'product', 'product', $through_date
     10885            ));
     10886            if (!empty($wpdb->last_error)) {
     10887                throw new RuntimeException('WP Statistics snapshot query failed.');
     10888            }
     10889            return [
     10890                'boundary' => ['max_page_id' => max(0, (int) $max_id), 'through_date' => $through_date],
     10891                'initial_checkpoint' => null,
     10892                'captured_at' => current_time('mysql'),
     10893            ];
     10894        }
     10895
     10896        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     10897            global $wpdb;
     10898            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     10899                throw new RuntimeException('Invalid WP Statistics origin or snapshot.');
     10900            }
     10901            if ($this->inspect_table()['status'] !== 'available') {
     10902                throw new RuntimeException('WP Statistics pages table became unavailable or incompatible.');
     10903            }
     10904            $boundary = $snapshot['boundary'];
     10905            $max_id = isset($boundary['max_page_id']) ? absint($boundary['max_page_id']) : -1;
     10906            $through_date = isset($boundary['through_date']) ? (string) $boundary['through_date'] : '';
     10907            $parsed = DateTime::createFromFormat('!Y-m-d', $through_date);
     10908            if ($max_id < 0 || !$parsed || $parsed->format('Y-m-d') !== $through_date) {
     10909                throw new RuntimeException('Invalid WP Statistics snapshot boundary.');
     10910            }
     10911            $cursor_id = 0;
     10912            if (is_array($checkpoint) && isset($checkpoint['cursor'])) {
     10913                $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']);
     10914            } elseif (is_numeric($checkpoint)) {
     10915                $cursor_id = absint($checkpoint);
     10916            }
     10917            $limit = max(1, min(100, absint($limit)));
     10918            $posts_table_name = $wpdb->posts;
     10919            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     10920                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     10921            }
     10922            $posts_table = '`' . $posts_table_name . '`';
     10923// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     10924            $rows = statpl_db_get_results(statpl_db_prepare(
     10925                'SELECT MAX(s.page_id) AS page_id, s.date, SUM(s.count) AS count, s.id
     10926                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s
     10927                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.id AND p.post_type = %s
     10928                 WHERE s.type = %s AND s.page_id <= %d AND s.date <= %s
     10929                 GROUP BY s.date, s.id
     10930                 HAVING MAX(s.page_id) > %d
     10931                 ORDER BY MAX(s.page_id) ASC
     10932                 LIMIT %d',
     10933                'product', 'product', $max_id, $through_date, $cursor_id, $limit + 1
     10934            ), ARRAY_A);
     10935            if (!empty($wpdb->last_error) || !is_array($rows)) {
     10936                throw new RuntimeException('WP Statistics batch query failed.');
     10937            }
     10938            $has_more = count($rows) > $limit;
     10939            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     10940            $next = $cursor_id;
     10941            if ($rows) { $last = end($rows); $next = absint($last['page_id']); }
     10942            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     10943        }
     10944
     10945        public function normalize_record($origin_key, $record) {
     10946            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; }
     10947            $record_id = isset($record['page_id']) ? absint($record['page_id']) : 0;
     10948            $product_id = isset($record['id']) ? absint($record['id']) : 0;
     10949            $views_raw = $record['count'] ?? null;
     10950            $date = isset($record['date']) ? (string) $record['date'] : '';
     10951            if (!$record_id || !$product_id || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; }
     10952            $parsed = DateTime::createFromFormat('!Y-m-d', $date);
     10953            $errors = DateTime::getLastErrors();
     10954            if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; }
     10955            $views = (int) $views_raw;
     10956            $period_start = $date . ' 00:00:00';
     10957            $period_end = $date . ' 23:59:59';
     10958            return [
     10959                'product_id' => $product_id,
     10960                'source_record_key' => 'wp_statistics_product_' . $product_id . '_' . $date,
     10961                'views' => $views,
     10962                'record_type' => 'aggregate',
     10963                'period_start' => $period_start,
     10964                'period_end' => $period_end,
     10965                'cursor' => $record_id,
     10966            ];
     10967        }
     10968    }
     10969}
     10970
     10971/**
     10972 * Post Views Counter adapter.
     10973 *
     10974 * Reads only type=0 daily rows from wp_post_views. Week, month, year and total
     10975 * rows are deliberately excluded because they are overlapping rollups of the
     10976 * same views. Past-day rows are used so the source counts are stable while the
     10977 * importer runs; today's row is still mutable and is left to live tracking.
     10978 */
     10979if (!class_exists('STATPL_Post_Views_Counter_Historical_Adapter')) {
     10980    final class STATPL_Post_Views_Counter_Historical_Adapter implements STATPL_Historical_View_Adapter {
     10981        const ADAPTER_KEY = 'post_views_counter';
     10982        const SOURCE_KEY = 'post_views_counter';
     10983        const ADAPTER_VERSION = '1.0.0';
     10984        const ORIGIN_KEY = 'daily_product_views';
     10985
     10986        public function get_adapter_key() { return self::ADAPTER_KEY; }
     10987        public function get_source_key() { return self::SOURCE_KEY; }
     10988        public function get_display_name() { return 'Post Views Counter'; }
     10989        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     10990
     10991        private function table_name() {
     10992            global $wpdb;
     10993            return $wpdb->prefix . 'post_views';
     10994        }
     10995
     10996        private function quoted_table_name() {
     10997            $table = $this->table_name();
     10998            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     10999                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     11000            }
     11001            return '`' . $table . '`';
     11002        }
     11003
     11004        private function inspect_table() {
     11005            global $wpdb;
     11006            $table = $this->table_name();
     11007            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     11008            if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11009            if ($exists !== $table) { return ['status' => 'missing_table']; }
     11010// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11011            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A);
     11012            if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11013            $names = [];
     11014            foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = (string) $column['Field']; } }
     11015            if (array_diff(['id', 'type', 'period', 'count'], $names)) { return ['status' => 'incompatible_schema']; }
     11016            return ['status' => 'available'];
     11017        }
     11018
     11019        public function should_register_adapter() {
     11020            global $wpdb;
     11021            $inspection = $this->inspect_table();
     11022            if ($inspection['status'] !== 'missing_table') { return true; }
     11023            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     11024            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     11025                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     11026            }
     11027            $registry_table = '`' . $registry_name . '`';
     11028            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     11029            if (!empty($wpdb->last_error)) { return true; }
     11030            if ($registry_exists !== $registry_name) { return false; }
     11031            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     11032// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11033            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     11034            return !empty($wpdb->last_error) || !empty($source_id);
     11035        }
     11036
     11037        public function discover_origins() {
     11038            $inspection = $this->inspect_table();
     11039            if ($inspection['status'] === 'missing_table') { return []; }
     11040            return [[
     11041                'origin_key' => self::ORIGIN_KEY,
     11042                'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'immutable_past_days' => true],
     11043                'detection_status' => $inspection['status'],
     11044            ]];
     11045        }
     11046
     11047        public function get_origin_snapshot($origin_key) {
     11048            global $wpdb;
     11049            if ($origin_key !== self::ORIGIN_KEY) { return []; }
     11050            if ($this->inspect_table()['status'] !== 'available') {
     11051                throw new RuntimeException('Post Views Counter table is unavailable or incompatible.');
     11052            }
     11053            $through_date = current_datetime()->modify('-1 day')->format('Ymd');
     11054            $posts_table_name = $wpdb->posts;
     11055            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     11056                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     11057            }
     11058            $posts_table = '`' . $posts_table_name . '`';
     11059            // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The query has four fixed value placeholders and receives four values; the two table identifiers are independently validated SQL identifiers.
     11060// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11061            $max_period = statpl_db_get_var(statpl_db_prepare(
     11062                'SELECT MAX(v.period)
     11063                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS v
     11064                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = v.id AND p.post_type = %s
     11065                 WHERE v.type = %d AND v.period REGEXP %s AND v.period <= %s',
     11066                'product', 0, '^[0-9]{8}$', $through_date
     11067            ));
     11068            if (!empty($wpdb->last_error)) {
     11069                throw new RuntimeException('Post Views Counter snapshot query failed.');
     11070            }
     11071            if (!is_string($max_period) || !preg_match('/^[0-9]{8}$/D', $max_period)) { $max_period = ''; }
     11072            return [
     11073                'boundary' => ['max_period' => $max_period],
     11074                'initial_checkpoint' => null,
     11075                'captured_at' => current_time('mysql'),
     11076            ];
     11077        }
     11078
     11079        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     11080            global $wpdb;
     11081            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     11082                throw new RuntimeException('Invalid Post Views Counter origin or snapshot.');
     11083            }
     11084            if ($this->inspect_table()['status'] !== 'available') {
     11085                throw new RuntimeException('Post Views Counter table became unavailable or incompatible.');
     11086            }
     11087            $max_period = isset($snapshot['boundary']['max_period']) ? (string) $snapshot['boundary']['max_period'] : '';
     11088            if ($max_period !== '' && !preg_match('/^[0-9]{8}$/D', $max_period)) { throw new RuntimeException('Invalid Post Views Counter snapshot boundary.'); }
     11089            $cursor_period = '';
     11090            $cursor_id = 0;
     11091            if (is_array($checkpoint) && isset($checkpoint['cursor']) && is_array($checkpoint['cursor'])) {
     11092                $cursor_period = isset($checkpoint['cursor']['period']) ? (string) $checkpoint['cursor']['period'] : '';
     11093                $cursor_id = isset($checkpoint['cursor']['id']) ? absint($checkpoint['cursor']['id']) : 0;
     11094            }
     11095            $limit = max(1, min(100, absint($limit)));
     11096            $posts_table_name = $wpdb->posts;
     11097            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     11098                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     11099            }
     11100            $posts_table = '`' . $posts_table_name . '`';
     11101            if ($max_period === '') { $rows = []; }
     11102            else {
     11103                $cursor_sql = '';
     11104                $args = ['product', 0, '^[0-9]{8}$', $max_period];
     11105                if ($cursor_period !== '') {
     11106                    if (!preg_match('/^[0-9]{8}$/D', $cursor_period)) { throw new RuntimeException('Invalid Post Views Counter cursor.'); }
     11107                    $cursor_sql = ' AND (v.period > %s OR (v.period = %s AND v.id > %d))';
     11108                    $args[] = $cursor_period;
     11109                    $args[] = $cursor_period;
     11110                    $args[] = $cursor_id;
     11111                }
     11112                $batch_sql = 'SELECT v.id, v.period, v.count
     11113                     FROM ' . $this->quoted_table_name() . ' AS v
     11114                     INNER JOIN ' . $posts_table . ' AS p ON p.ID = v.id AND p.post_type = %s
     11115                     WHERE v.type = %d AND v.period REGEXP %s AND v.period <= %s' . $cursor_sql . '
     11116                     ORDER BY v.period ASC, v.id ASC
     11117                     LIMIT %d';
     11118                if ($cursor_period !== '') {
     11119// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11120                    $rows = statpl_db_get_results(statpl_db_prepare(
     11121                        /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql,
     11122                        'product', 0, '^[0-9]{8}$', $max_period,
     11123                        $cursor_period, $cursor_period, $cursor_id, $limit + 1
     11124                    ), ARRAY_A);
     11125                } else {
     11126// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11127                    $rows = statpl_db_get_results(statpl_db_prepare(
     11128                        /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql,
     11129                        'product', 0, '^[0-9]{8}$', $max_period, $limit + 1
     11130                    ), ARRAY_A);
     11131                }
     11132            }
     11133            if (!empty($wpdb->last_error) || !is_array($rows)) {
     11134                throw new RuntimeException('Post Views Counter batch query failed.');
     11135            }
     11136            $has_more = count($rows) > $limit;
     11137            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     11138            $next = ($cursor_period !== '') ? ['period' => $cursor_period, 'id' => $cursor_id] : null;
     11139            if ($rows) { $last = end($rows); $next = ['period' => (string) $last['period'], 'id' => absint($last['id'])]; }
     11140            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     11141        }
     11142
     11143        public function normalize_record($origin_key, $record) {
     11144            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; }
     11145            $product_id = isset($record['id']) ? absint($record['id']) : 0;
     11146            $period = isset($record['period']) ? (string) $record['period'] : '';
     11147            $views_raw = $record['count'] ?? null;
     11148            if (!$product_id || !preg_match('/^[0-9]{8}$/D', $period) || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; }
     11149            $parsed = DateTime::createFromFormat('!Ymd', $period);
     11150            $errors = DateTime::getLastErrors();
     11151            if (!$parsed || $parsed->format('Ymd') !== $period || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; }
     11152            $date = $parsed->format('Y-m-d');
     11153            $views = (int) $views_raw;
     11154            $period_start = $date . ' 00:00:00';
     11155            $period_end = $date . ' 23:59:59';
     11156            return [
     11157                'product_id' => $product_id,
     11158                'source_record_key' => 'post_views_counter_day_' . $period . '_' . $product_id,
     11159                'views' => $views,
     11160                'record_type' => 'aggregate',
     11161                'period_start' => $period_start,
     11162                'period_end' => $period_end,
     11163                'cursor' => ['period' => $period, 'id' => $product_id],
     11164            ];
     11165        }
     11166    }
     11167}
     11168
     11169add_filter('statpl_historical_view_adapters', static function ($adapters) {
     11170    if (!is_array($adapters)) { return $adapters; }
     11171    $wp_statistics = new STATPL_WP_Statistics_Historical_Adapter();
     11172    if ($wp_statistics->should_register_adapter()) { $adapters[] = $wp_statistics; }
     11173    $post_views_counter = new STATPL_Post_Views_Counter_Historical_Adapter();
     11174    if ($post_views_counter->should_register_adapter()) { $adapters[] = $post_views_counter; }
     11175    return $adapters;
     11176}, 31);
     11177
     11178/**
     11179 * Statify adapter.
     11180 *
     11181 * Statify stores one immutable row per page view, but its schema contains only
     11182 * a calendar date and target URL (not a timestamp or WordPress post ID). The
     11183 * adapter resolves same-site targets to published WooCommerce products and
     11184 * explicitly skips non-product rows. It does not issue cross-source fingerprints:
     11185 * a date-only row cannot be safely matched one-to-one against another provider's
     11186 * event stream without risking suppression of legitimate repeat views.
     11187 */
     11188if (!class_exists('STATPL_Statify_Historical_Adapter')) {
     11189    final class STATPL_Statify_Historical_Adapter implements STATPL_Historical_View_Adapter {
     11190        const ADAPTER_KEY = 'statify';
     11191        const SOURCE_KEY = 'statify';
     11192        const ADAPTER_VERSION = '1.0.0';
     11193        const ORIGIN_KEY = 'page_view_events';
     11194
     11195        public function get_adapter_key() { return self::ADAPTER_KEY; }
     11196        public function get_source_key() { return self::SOURCE_KEY; }
     11197        public function get_display_name() { return 'Statify'; }
     11198        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     11199
     11200        private function table_name() {
     11201            global $wpdb;
     11202            return $wpdb->prefix . 'statify';
     11203        }
     11204
     11205        private function quoted_table_name() {
     11206            $table = $this->table_name();
     11207            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     11208                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     11209            }
     11210            return '`' . $table . '`';
     11211        }
     11212
     11213        private function inspect_table() {
     11214            global $wpdb;
     11215            $table = $this->table_name();
     11216            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     11217            if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11218            if ($exists !== $table) { return ['status' => 'missing_table']; }
     11219// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11220            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A);
     11221            if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11222            $names = [];
     11223            foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = (string) $column['Field']; } }
     11224            if (array_diff(['id', 'created', 'referrer', 'target'], $names)) { return ['status' => 'incompatible_schema']; }
     11225            return ['status' => 'available'];
     11226        }
     11227
     11228        public function should_register_adapter() {
     11229            global $wpdb;
     11230            $inspection = $this->inspect_table();
     11231            if ($inspection['status'] !== 'missing_table') { return true; }
     11232            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     11233            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     11234                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     11235            }
     11236            $registry_table = '`' . $registry_name . '`';
     11237            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     11238            if (!empty($wpdb->last_error)) { return true; }
     11239            if ($registry_exists !== $registry_name) { return false; }
     11240            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     11241// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11242            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     11243            return !empty($wpdb->last_error) || !empty($source_id);
     11244        }
     11245
     11246        public function discover_origins() {
     11247            $inspection = $this->inspect_table();
     11248            if ($inspection['status'] === 'missing_table') { return []; }
     11249            return [[
     11250                'origin_key' => self::ORIGIN_KEY,
     11251                'capabilities' => ['product_views' => true, 'granularity' => 'event_date_only', 'target_requires_product_resolution' => true],
     11252                'detection_status' => $inspection['status'],
     11253            ]];
     11254        }
     11255
     11256        public function get_origin_snapshot($origin_key) {
     11257            global $wpdb;
     11258            if ($origin_key !== self::ORIGIN_KEY) { return []; }
     11259            if ($this->inspect_table()['status'] !== 'available') {
     11260                throw new RuntimeException('Statify table is unavailable or incompatible.');
     11261            }
     11262// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11263            $max_id = statpl_db_get_var('SELECT MAX(id) FROM ' . $this->quoted_table_name());
     11264            if (!empty($wpdb->last_error)) {
     11265                throw new RuntimeException('Statify snapshot query failed.');
     11266            }
     11267            return [
     11268                'boundary' => ['max_id' => max(0, (int) $max_id)],
     11269                'initial_checkpoint' => null,
     11270                'captured_at' => current_time('mysql'),
     11271            ];
     11272        }
     11273
     11274        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     11275            global $wpdb;
     11276            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     11277                throw new RuntimeException('Invalid Statify origin or snapshot.');
     11278            }
     11279            if ($this->inspect_table()['status'] !== 'available') {
     11280                throw new RuntimeException('Statify table became unavailable or incompatible.');
     11281            }
     11282            $max_id = isset($snapshot['boundary']['max_id']) ? absint($snapshot['boundary']['max_id']) : -1;
     11283            if ($max_id < 0) { throw new RuntimeException('Invalid Statify snapshot boundary.'); }
     11284            $cursor_id = 0;
     11285            if (is_array($checkpoint) && array_key_exists('cursor', $checkpoint)) {
     11286                $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']);
     11287            } elseif (is_numeric($checkpoint)) {
     11288                $cursor_id = absint($checkpoint);
     11289            }
     11290            $limit = max(1, min(100, absint($limit)));
     11291// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11292            $rows = statpl_db_get_results(statpl_db_prepare(
     11293                'SELECT id, created, referrer, target
     11294                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . '
     11295                 WHERE id > %d AND id <= %d
     11296                 ORDER BY id ASC
     11297                 LIMIT %d',
     11298                $cursor_id,
     11299                $max_id,
     11300                $limit + 1
     11301            ), ARRAY_A);
     11302            if (!empty($wpdb->last_error) || !is_array($rows)) {
     11303                throw new RuntimeException('Statify batch query failed.');
     11304            }
     11305            $has_more = count($rows) > $limit;
     11306            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     11307            $next = $cursor_id;
     11308            if ($rows) { $last = end($rows); $next = absint($last['id']); }
     11309            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     11310        }
     11311
     11312        private function resolve_product_id($target) {
     11313            if (!is_string($target) || trim($target) === '' || !function_exists('url_to_postid')) { return 0; }
     11314            $target = trim($target);
     11315            if (preg_match('#^https?://#i', $target)) {
     11316                $url = $target;
     11317            } elseif (strpos($target, '//') === 0) {
     11318                return 0;
     11319            } else {
     11320                $url = home_url('/' . ltrim($target, '/'));
     11321            }
     11322            $target_host = strtolower((string) wp_parse_url($url, PHP_URL_HOST));
     11323            $home_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
     11324            if ($target_host === '' || $home_host === '' || $target_host !== $home_host) { return 0; }
     11325            $product_id = absint(url_to_postid($url));
     11326            if (!$product_id || get_post_type($product_id) !== 'product') { return 0; }
     11327            return $product_id;
     11328        }
     11329
     11330        public function normalize_record($origin_key, $record) {
     11331            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; }
     11332            $record_id = isset($record['id']) ? absint($record['id']) : 0;
     11333            $record_key = $record_id ? 'statify_view_' . $record_id : '';
     11334            $cursor = $record_id;
     11335            if (!$record_id) { return null; }
     11336            $date = isset($record['created']) ? (string) $record['created'] : '';
     11337            $parsed = DateTime::createFromFormat('!Y-m-d', $date);
     11338            $errors = DateTime::getLastErrors();
     11339            if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) {
     11340                return null;
     11341            }
     11342            $product_id = $this->resolve_product_id(isset($record['target']) ? (string) $record['target'] : '');
     11343            if (!$product_id) {
     11344                return ['skip' => true, 'source_record_key' => $record_key, 'cursor' => $cursor];
     11345            }
     11346            $referrer = isset($record['referrer']) ? (string) $record['referrer'] : '';
     11347            return [
     11348                'product_id' => $product_id,
     11349                'source_record_key' => $record_key,
     11350                'views' => 1,
     11351                'record_type' => 'event',
     11352                'period_start' => $date . ' 00:00:00',
     11353                'period_end' => $date . ' 23:59:59',
     11354                'cursor' => $cursor,
     11355                'source_referrer_hash' => hash('sha256', $referrer),
     11356            ];
     11357        }
     11358
     11359        public function get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event) {
     11360            return [
     11361                'source' => 'statify',
     11362                'date_precision' => 'calendar_day_only',
     11363                'referrer_sha256' => isset($normalized_record['source_referrer_hash']) ? $normalized_record['source_referrer_hash'] : null,
     11364                'overlap_assessment' => 'possible_but_not_proven',
     11365                'automatic_suppression' => false,
     11366                'reason' => 'Statify has no per-view timestamp or stable cross-provider event identifier; a one-to-one match would risk suppressing legitimate repeat views.',
     11367            ];
     11368        }
     11369    }
     11370}
     11371
     11372add_filter('statpl_historical_view_adapters', static function ($adapters) {
     11373    if (!is_array($adapters)) { return $adapters; }
     11374    $adapter = new STATPL_Statify_Historical_Adapter();
     11375    if ($adapter->should_register_adapter()) { $adapters[] = $adapter; }
     11376    return $adapters;
     11377}, 32);
     11378
     11379/**
     11380 * WP Popular Posts adapter.
     11381 *
     11382 * Imports the per-post/per-day summary table only. The all-time data table is
     11383 * deliberately excluded because it overlaps the daily summary and carries no
     11384 * date range. WPP's configurable log retention determines how much daily
     11385 * history remains available at the time Statora captures its snapshot.
     11386 */
     11387if (!class_exists('STATPL_WP_Popular_Posts_Historical_Adapter')) {
     11388    final class STATPL_WP_Popular_Posts_Historical_Adapter implements STATPL_Historical_View_Adapter {
     11389        const ADAPTER_KEY = 'wp_popular_posts';
     11390        const SOURCE_KEY = 'wp_popular_posts';
     11391        const ADAPTER_VERSION = '1.0.0';
     11392        const ORIGIN_KEY = 'product_daily_summary';
     11393
     11394        public function get_adapter_key() { return self::ADAPTER_KEY; }
     11395        public function get_source_key() { return self::SOURCE_KEY; }
     11396        public function get_display_name() { return 'WP Popular Posts'; }
     11397        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     11398
     11399        private function table_name() {
     11400            global $wpdb;
     11401            return $wpdb->prefix . 'popularpostssummary';
     11402        }
     11403
     11404        private function quoted_table_name() {
     11405            $table = $this->table_name();
     11406            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     11407                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     11408            }
     11409            return '`' . $table . '`';
     11410        }
     11411
     11412        private function inspect_table() {
     11413            global $wpdb;
     11414            $table = $this->table_name();
     11415            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     11416            if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11417            if ($exists !== $table) { return ['status' => 'missing_table']; }
     11418// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11419            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A);
     11420            if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11421            $names = [];
     11422            foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } }
     11423            if (array_diff(['id', 'postid', 'pageviews', 'view_date', 'last_viewed'], $names)) { return ['status' => 'incompatible_schema']; }
     11424            return ['status' => 'available'];
     11425        }
     11426
     11427        public function should_register_adapter() {
     11428            global $wpdb;
     11429            $inspection = $this->inspect_table();
     11430            if ($inspection['status'] !== 'missing_table') { return true; }
     11431            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     11432            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     11433                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     11434            }
     11435            $registry_table = '`' . $registry_name . '`';
     11436            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     11437            if (!empty($wpdb->last_error)) { return true; }
     11438            if ($registry_exists !== $registry_name) { return false; }
     11439            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     11440// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11441            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     11442            return !empty($wpdb->last_error) || !empty($source_id);
     11443        }
     11444
     11445        public function discover_origins() {
     11446            $inspection = $this->inspect_table();
     11447            if ($inspection['status'] === 'missing_table') { return []; }
     11448            return [[
     11449                'origin_key' => self::ORIGIN_KEY,
     11450                'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'immutable_past_days' => true],
     11451                'detection_status' => $inspection['status'],
     11452            ]];
     11453        }
     11454
     11455        public function get_origin_snapshot($origin_key) {
     11456            global $wpdb;
     11457            if ($origin_key !== self::ORIGIN_KEY) { return []; }
     11458            if ($this->inspect_table()['status'] !== 'available') {
     11459                throw new RuntimeException('WP Popular Posts summary table is unavailable or incompatible.');
     11460            }
     11461            $through_date = current_datetime()->modify('-1 day')->format('Y-m-d');
     11462            $posts_table_name = $wpdb->posts;
     11463            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     11464                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     11465            }
     11466            $posts_table = '`' . $posts_table_name . '`';
     11467// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11468            $max_id = statpl_db_get_var(statpl_db_prepare(
     11469                'SELECT MAX(s.ID)
     11470                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s
     11471                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.postid AND p.post_type = %s
     11472                 WHERE s.view_date <= %s',
     11473                'product', $through_date
     11474            ));
     11475            if (!empty($wpdb->last_error)) {
     11476                throw new RuntimeException('WP Popular Posts snapshot query failed.');
     11477            }
     11478            return [
     11479                'boundary' => ['max_id' => max(0, (int) $max_id), 'through_date' => $through_date],
     11480                'initial_checkpoint' => null,
     11481                'captured_at' => current_time('mysql'),
     11482            ];
     11483        }
     11484
     11485        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     11486            global $wpdb;
     11487            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     11488                throw new RuntimeException('Invalid WP Popular Posts origin or snapshot.');
     11489            }
     11490            if ($this->inspect_table()['status'] !== 'available') {
     11491                throw new RuntimeException('WP Popular Posts summary table became unavailable or incompatible.');
     11492            }
     11493            $boundary = $snapshot['boundary'];
     11494            $max_id = isset($boundary['max_id']) ? absint($boundary['max_id']) : -1;
     11495            $through_date = isset($boundary['through_date']) ? (string) $boundary['through_date'] : '';
     11496            $parsed = DateTime::createFromFormat('!Y-m-d', $through_date);
     11497            if ($max_id < 0 || !$parsed || $parsed->format('Y-m-d') !== $through_date) {
     11498                throw new RuntimeException('Invalid WP Popular Posts snapshot boundary.');
     11499            }
     11500            $cursor_id = 0;
     11501            if (is_array($checkpoint) && isset($checkpoint['cursor'])) {
     11502                $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']);
     11503            } elseif (is_numeric($checkpoint)) { $cursor_id = absint($checkpoint); }
     11504            $limit = max(1, min(100, absint($limit)));
     11505            $posts_table_name = $wpdb->posts;
     11506            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     11507                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     11508            }
     11509            $posts_table = '`' . $posts_table_name . '`';
     11510// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11511            $rows = statpl_db_get_results(statpl_db_prepare(
     11512                'SELECT s.ID AS id, s.postid, s.pageviews, s.view_date
     11513                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s
     11514                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.postid AND p.post_type = %s
     11515                 WHERE s.ID > %d AND s.ID <= %d AND s.view_date <= %s
     11516                 ORDER BY s.ID ASC
     11517                 LIMIT %d',
     11518                'product', $cursor_id, $max_id, $through_date, $limit + 1
     11519            ), ARRAY_A);
     11520            if (!empty($wpdb->last_error) || !is_array($rows)) {
     11521                throw new RuntimeException('WP Popular Posts batch query failed.');
     11522            }
     11523            $has_more = count($rows) > $limit;
     11524            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     11525            $next = $cursor_id;
     11526            if ($rows) { $last = end($rows); $next = absint($last['id']); }
     11527            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     11528        }
     11529
     11530        public function normalize_record($origin_key, $record) {
     11531            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; }
     11532            $record_id = isset($record['id']) ? absint($record['id']) : 0;
     11533            $product_id = isset($record['postid']) ? absint($record['postid']) : 0;
     11534            $views_raw = $record['pageviews'] ?? null;
     11535            $date = isset($record['view_date']) ? (string) $record['view_date'] : '';
     11536            if (!$record_id || !$product_id || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; }
     11537            $parsed = DateTime::createFromFormat('!Y-m-d', $date);
     11538            $errors = DateTime::getLastErrors();
     11539            if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; }
     11540            $views = (int) $views_raw;
     11541            $period_start = $date . ' 00:00:00';
     11542            $period_end = $date . ' 23:59:59';
     11543            return [
     11544                'product_id' => $product_id,
     11545                'source_record_key' => 'wp_popular_posts_' . $product_id . '_' . $date,
     11546                'views' => $views,
     11547                'record_type' => 'aggregate',
     11548                'period_start' => $period_start,
     11549                'period_end' => $period_end,
     11550                'cursor' => $record_id,
     11551            ];
     11552        }
     11553    }
     11554}
     11555
     11556add_filter('statpl_historical_view_adapters', static function ($adapters) {
     11557    if (!is_array($adapters)) { return $adapters; }
     11558    $adapter = new STATPL_WP_Popular_Posts_Historical_Adapter();
     11559    if ($adapter->should_register_adapter()) { $adapters[] = $adapter; }
     11560    return $adapters;
     11561}, 33);
     11562
     11563/**
     11564 * WebberZone Top 10 adapter.
     11565 *
     11566 * Imports only the per-post hourly rows from top_ten_daily, grouped into local
     11567 * calendar-day totals. The all-time table, visits funnel and visits log are not
     11568 * imported in parallel: the funnel/log feed the same daily and lifetime counts.
     11569 * A two-day safety lag allows Top 10's background aggregation cron to drain
     11570 * completed days before the snapshot is taken.
     11571 */
     11572if (!class_exists('STATPL_Top_Ten_Historical_Adapter')) {
     11573    final class STATPL_Top_Ten_Historical_Adapter implements STATPL_Historical_View_Adapter {
     11574        const ADAPTER_KEY = 'webberzone_top_10';
     11575        const SOURCE_KEY = 'webberzone_top_10';
     11576        const ADAPTER_VERSION = '1.0.0';
     11577        const ORIGIN_KEY = 'product_daily_counts';
     11578
     11579        public function get_adapter_key() { return self::ADAPTER_KEY; }
     11580        public function get_source_key() { return self::SOURCE_KEY; }
     11581        public function get_display_name() { return 'WebberZone Top 10'; }
     11582        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     11583
     11584        private function table_name() {
     11585            global $wpdb;
     11586            return $wpdb->base_prefix . 'top_ten_daily';
     11587        }
     11588
     11589        private function quoted_table_name() {
     11590            $table = $this->table_name();
     11591            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     11592                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     11593            }
     11594            return '`' . $table . '`';
     11595        }
     11596
     11597        private function inspect_table() {
     11598            global $wpdb;
     11599            $table = $this->table_name();
     11600            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     11601            if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11602            if ($exists !== $table) { return ['status' => 'missing_table']; }
     11603// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11604            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A);
     11605            if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11606            $names = [];
     11607            foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } }
     11608            if (array_diff(['postnumber', 'cntaccess', 'dp_date', 'blog_id'], $names)) { return ['status' => 'incompatible_schema']; }
     11609            return ['status' => 'available', 'has_feed_column' => in_array('feedcnt', $names, true)];
     11610        }
     11611
     11612        public function should_register_adapter() {
     11613            global $wpdb;
     11614            $inspection = $this->inspect_table();
     11615            if ($inspection['status'] !== 'missing_table') { return true; }
     11616            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     11617            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     11618                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     11619            }
     11620            $registry_table = '`' . $registry_name . '`';
     11621            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     11622            if (!empty($wpdb->last_error)) { return true; }
     11623            if ($registry_exists !== $registry_name) { return false; }
     11624            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     11625// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11626            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     11627            return !empty($wpdb->last_error) || !empty($source_id);
     11628        }
     11629
     11630        public function discover_origins() {
     11631            $inspection = $this->inspect_table();
     11632            if ($inspection['status'] === 'missing_table') { return []; }
     11633            return [[
     11634                'origin_key' => self::ORIGIN_KEY,
     11635                'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'source_storage_granularity' => 'hourly', 'immutable_past_days' => true],
     11636                'detection_status' => $inspection['status'],
     11637            ]];
     11638        }
     11639
     11640        public function get_origin_snapshot($origin_key) {
     11641            if ($origin_key !== self::ORIGIN_KEY) { return []; }
     11642            if ($this->inspect_table()['status'] !== 'available') {
     11643                throw new RuntimeException('Top 10 daily table is unavailable or incompatible.');
     11644            }
     11645            $through_date = current_datetime()->modify('-2 days')->format('Y-m-d');
     11646            return [
     11647                'boundary' => ['through_date' => $through_date, 'blog_id' => absint(get_current_blog_id())],
     11648                'initial_checkpoint' => null,
     11649                'captured_at' => current_time('mysql'),
     11650            ];
     11651        }
     11652
     11653        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     11654            global $wpdb;
     11655            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     11656                throw new RuntimeException('Invalid Top 10 origin or snapshot.');
     11657            }
     11658            if ($this->inspect_table()['status'] !== 'available') {
     11659                throw new RuntimeException('Top 10 daily table became unavailable or incompatible.');
     11660            }
     11661            $through_date = isset($snapshot['boundary']['through_date']) ? (string) $snapshot['boundary']['through_date'] : '';
     11662            $parsed = DateTime::createFromFormat('!Y-m-d', $through_date);
     11663            $blog_id = isset($snapshot['boundary']['blog_id']) ? absint($snapshot['boundary']['blog_id']) : 0;
     11664            if (!$parsed || $parsed->format('Y-m-d') !== $through_date || !$blog_id) {
     11665                throw new RuntimeException('Invalid Top 10 snapshot boundary.');
     11666            }
     11667            $cursor_date = '';
     11668            $cursor_product = 0;
     11669            if (is_array($checkpoint) && isset($checkpoint['cursor']) && is_array($checkpoint['cursor'])) {
     11670                $cursor_date = isset($checkpoint['cursor']['date']) ? (string) $checkpoint['cursor']['date'] : '';
     11671                $cursor_product = isset($checkpoint['cursor']['product_id']) ? absint($checkpoint['cursor']['product_id']) : 0;
     11672            }
     11673            if ($cursor_date !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/D', $cursor_date) || !DateTime::createFromFormat('!Y-m-d', $cursor_date))) {
     11674                throw new RuntimeException('Invalid Top 10 cursor date.');
     11675            }
     11676            $limit = max(1, min(100, absint($limit)));
     11677            $posts_table_name = $wpdb->posts;
     11678            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     11679                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     11680            }
     11681            $posts_table = '`' . $posts_table_name . '`';
     11682            $inspection = $this->inspect_table();
     11683            $view_sum_sql = !empty($inspection['has_feed_column'])
     11684                ? 'SUM(GREATEST(t.cntaccess - t.feedcnt, 0))'
     11685                : 'SUM(t.cntaccess)';
     11686            if (!in_array($view_sum_sql, ['SUM(GREATEST(t.cntaccess - t.feedcnt, 0))', 'SUM(t.cntaccess)'], true)) {
     11687                throw new RuntimeException('Unsupported Top 10 view aggregation expression.');
     11688            }
     11689            $cursor_sql = '';
     11690            $args = ['product', $blog_id, $through_date];
     11691            if ($cursor_date !== '') {
     11692                $cursor_sql = ' HAVING (view_date > %s OR (view_date = %s AND product_id > %d))';
     11693                $args[] = $cursor_date;
     11694                $args[] = $cursor_date;
     11695                $args[] = $cursor_product;
     11696            }
     11697            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $view_sum_sql is selected from two fixed aggregate expressions and checked against the same strict allowlist; all runtime values remain prepared placeholders.
     11698            $batch_sql = 'SELECT DATE(t.dp_date) AS view_date, t.postnumber AS product_id, ' . $view_sum_sql . ' AS views
     11699                 FROM ' . $this->quoted_table_name() . ' AS t
     11700                 INNER JOIN ' . $posts_table . ' AS p ON p.ID = t.postnumber AND p.post_type = %s
     11701                 WHERE t.blog_id = %d AND t.dp_date < DATE_ADD(%s, INTERVAL 1 DAY)
     11702                 GROUP BY DATE(t.dp_date), t.postnumber' . $cursor_sql . '
     11703                 ORDER BY view_date ASC, product_id ASC
     11704                 LIMIT %d';
     11705            if ($cursor_date !== '') {
     11706// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11707                $rows = statpl_db_get_results(statpl_db_prepare(
     11708                    /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql,
     11709                    'product', $blog_id, $through_date,
     11710                    $cursor_date, $cursor_date, $cursor_product, $limit + 1
     11711                ), ARRAY_A);
     11712            } else {
     11713// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11714                $rows = statpl_db_get_results(statpl_db_prepare(
     11715                    /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql,
     11716                    'product', $blog_id, $through_date, $limit + 1
     11717                ), ARRAY_A);
     11718            }
     11719            if (!empty($wpdb->last_error) || !is_array($rows)) {
     11720                throw new RuntimeException('Top 10 batch query failed.');
     11721            }
     11722            $has_more = count($rows) > $limit;
     11723            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     11724            $next = ($cursor_date !== '') ? ['date' => $cursor_date, 'product_id' => $cursor_product] : null;
     11725            if ($rows) { $last = end($rows); $next = ['date' => (string) $last['view_date'], 'product_id' => absint($last['product_id'])]; }
     11726            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     11727        }
     11728
     11729        public function normalize_record($origin_key, $record) {
     11730            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; }
     11731            $product_id = isset($record['product_id']) ? absint($record['product_id']) : 0;
     11732            $date = isset($record['view_date']) ? (string) $record['view_date'] : '';
     11733            $views_raw = $record['views'] ?? null;
     11734            if (!$product_id || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; }
     11735            $parsed = DateTime::createFromFormat('!Y-m-d', $date);
     11736            $errors = DateTime::getLastErrors();
     11737            if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; }
     11738            $views = (int) $views_raw;
     11739            $period_start = $date . ' 00:00:00';
     11740            $period_end = $date . ' 23:59:59';
     11741            return [
     11742                'product_id' => $product_id,
     11743                'source_record_key' => 'top_10_' . $product_id . '_' . $date,
     11744                'views' => $views,
     11745                'record_type' => 'aggregate',
     11746                'period_start' => $period_start,
     11747                'period_end' => $period_end,
     11748                'cursor' => ['date' => $date, 'product_id' => $product_id],
     11749            ];
     11750        }
     11751    }
     11752}
     11753
     11754add_filter('statpl_historical_view_adapters', static function ($adapters) {
     11755    if (!is_array($adapters)) { return $adapters; }
     11756    $adapter = new STATPL_Top_Ten_Historical_Adapter();
     11757    if ($adapter->should_register_adapter()) { $adapters[] = $adapter; }
     11758    return $adapters;
     11759}, 34);
     11760
     11761/**
     11762 * Koko Analytics adapter.
     11763 *
     11764 * Reads the per-page daily statistics table. Current Koko schemas use path_id
     11765 * and post_id; older schemas used id directly as the WordPress post ID. Rows
     11766 * are grouped by product/date because one product can be represented by more
     11767 * than one canonicalized path. Only completed days are imported.
     11768 */
     11769if (!class_exists('STATPL_Koko_Analytics_Historical_Adapter')) {
     11770    final class STATPL_Koko_Analytics_Historical_Adapter implements STATPL_Historical_View_Adapter {
     11771        const ADAPTER_KEY = 'koko_analytics';
     11772        const SOURCE_KEY = 'koko_analytics';
     11773        const ADAPTER_VERSION = '1.0.0';
     11774        const ORIGIN_KEY = 'product_daily_stats';
     11775
     11776        public function get_adapter_key() { return self::ADAPTER_KEY; }
     11777        public function get_source_key() { return self::SOURCE_KEY; }
     11778        public function get_display_name() { return 'Koko Analytics'; }
     11779        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     11780
     11781        private function table_name() {
     11782            global $wpdb;
     11783            return $wpdb->prefix . 'koko_analytics_post_stats';
     11784        }
     11785
     11786        private function quoted_table_name() {
     11787            $table = $this->table_name();
     11788            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     11789                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     11790            }
     11791            return '`' . $table . '`';
     11792        }
     11793
     11794        private function inspect_table() {
     11795            global $wpdb;
     11796            $table = $this->table_name();
     11797            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     11798            if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11799            if ($exists !== $table) { return ['status' => 'missing_table']; }
     11800// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11801            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A);
     11802            if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     11803            $names = [];
     11804            foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } }
     11805            if (!in_array('date', $names, true) || !in_array('pageviews', $names, true)) { return ['status' => 'incompatible_schema']; }
     11806            if (in_array('post_id', $names, true) && in_array('path_id', $names, true)) {
     11807                return ['status' => 'available', 'schema_mode' => 'path_id'];
     11808            }
     11809            if (in_array('id', $names, true)) {
     11810                return ['status' => 'available', 'schema_mode' => 'legacy_post_id'];
     11811            }
     11812            return ['status' => 'incompatible_schema'];
     11813        }
     11814
     11815        public function should_register_adapter() {
     11816            global $wpdb;
     11817            $inspection = $this->inspect_table();
     11818            if ($inspection['status'] !== 'missing_table') { return true; }
     11819            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     11820            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     11821                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     11822            }
     11823            $registry_table = '`' . $registry_name . '`';
     11824            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     11825            if (!empty($wpdb->last_error)) { return true; }
     11826            if ($registry_exists !== $registry_name) { return false; }
     11827            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     11828// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11829            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     11830            return !empty($wpdb->last_error) || !empty($source_id);
     11831        }
     11832
     11833        public function discover_origins() {
     11834            $inspection = $this->inspect_table();
     11835            if ($inspection['status'] === 'missing_table') { return []; }
     11836            return [[
     11837                'origin_key' => self::ORIGIN_KEY,
     11838                'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'schema_mode' => isset($inspection['schema_mode']) ? $inspection['schema_mode'] : 'unknown'],
     11839                'detection_status' => $inspection['status'],
     11840            ]];
     11841        }
     11842
     11843        public function get_origin_snapshot($origin_key) {
     11844            if ($origin_key !== self::ORIGIN_KEY) { return []; }
     11845            $inspection = $this->inspect_table();
     11846            if ($inspection['status'] !== 'available') {
     11847                throw new RuntimeException('Koko Analytics post statistics table is unavailable or incompatible.');
     11848            }
     11849            return [
     11850                'boundary' => [
     11851                    'through_date' => current_datetime()->modify('-1 day')->format('Y-m-d'),
     11852                    'schema_mode' => $inspection['schema_mode'],
     11853                ],
     11854                'initial_checkpoint' => null,
     11855                'captured_at' => current_time('mysql'),
     11856            ];
     11857        }
     11858
     11859        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     11860            global $wpdb;
     11861            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     11862                throw new RuntimeException('Invalid Koko Analytics origin or snapshot.');
     11863            }
     11864            $inspection = $this->inspect_table();
     11865            if ($inspection['status'] !== 'available') {
     11866                throw new RuntimeException('Koko Analytics post statistics table became unavailable or incompatible.');
     11867            }
     11868            $boundary = $snapshot['boundary'];
     11869            $through_date = isset($boundary['through_date']) ? (string) $boundary['through_date'] : '';
     11870            $parsed = DateTime::createFromFormat('!Y-m-d', $through_date);
     11871            $schema_mode = isset($boundary['schema_mode']) ? (string) $boundary['schema_mode'] : '';
     11872            if (!$parsed || $parsed->format('Y-m-d') !== $through_date || $schema_mode !== $inspection['schema_mode']) {
     11873                throw new RuntimeException('Invalid or changed Koko Analytics snapshot boundary.');
     11874            }
     11875            $cursor_date = '';
     11876            $cursor_product = 0;
     11877            if (is_array($checkpoint) && isset($checkpoint['cursor']) && is_array($checkpoint['cursor'])) {
     11878                $cursor_date = isset($checkpoint['cursor']['date']) ? (string) $checkpoint['cursor']['date'] : '';
     11879                $cursor_product = isset($checkpoint['cursor']['product_id']) ? absint($checkpoint['cursor']['product_id']) : 0;
     11880            }
     11881            if ($cursor_date !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/D', $cursor_date) || !DateTime::createFromFormat('!Y-m-d', $cursor_date))) {
     11882                throw new RuntimeException('Invalid Koko Analytics cursor date.');
     11883            }
     11884            $limit = max(1, min(100, absint($limit)));
     11885            $posts_table_name = $wpdb->posts;
     11886            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     11887                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     11888            }
     11889            $posts_table = '`' . $posts_table_name . '`';
     11890            if (!in_array($schema_mode, ['path_id', 'id'], true)) {
     11891                throw new RuntimeException('Unsupported Koko Analytics schema mode.');
     11892            }
     11893            $post_id_column = $schema_mode === 'path_id' ? 's.post_id' : 's.id';
     11894            if (!in_array($post_id_column, ['s.post_id', 's.id'], true)) {
     11895                throw new RuntimeException('Unsupported Koko Analytics product identifier column.');
     11896            }
     11897            $cursor_sql = '';
     11898            $args = ['product', $through_date];
     11899            if ($cursor_date !== '') {
     11900                $cursor_sql = ' HAVING (view_date > %s OR (view_date = %s AND product_id > %d))';
     11901                $args[] = $cursor_date;
     11902                $args[] = $cursor_date;
     11903                $args[] = $cursor_product;
     11904            }
     11905            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $post_id_column is selected from the fixed allowlist s.post_id/s.id after schema-mode validation; runtime values remain prepared placeholders.
     11906            $batch_sql = 'SELECT s.date AS view_date, ' . $post_id_column . ' AS product_id, SUM(s.pageviews) AS views
     11907                 FROM ' . $this->quoted_table_name() . ' AS s
     11908                 INNER JOIN ' . $posts_table . ' AS p ON p.ID = ' . $post_id_column . ' AND p.post_type = %s
     11909                 WHERE s.date <= %s
     11910                 GROUP BY s.date, ' . $post_id_column . $cursor_sql . '
     11911                 ORDER BY view_date ASC, product_id ASC
     11912                 LIMIT %d';
     11913            if ($cursor_date !== '') {
     11914// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11915                $rows = statpl_db_get_results(statpl_db_prepare(
     11916                    /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql,
     11917                    'product', $through_date,
     11918                    $cursor_date, $cursor_date, $cursor_product, $limit + 1
     11919                ), ARRAY_A);
     11920            } else {
     11921// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     11922                $rows = statpl_db_get_results(statpl_db_prepare(
     11923                    /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $batch_sql,
     11924                    'product', $through_date, $limit + 1
     11925                ), ARRAY_A);
     11926            }
     11927            if (!empty($wpdb->last_error) || !is_array($rows)) {
     11928                throw new RuntimeException('Koko Analytics batch query failed.');
     11929            }
     11930            $has_more = count($rows) > $limit;
     11931            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     11932            $next = ($cursor_date !== '') ? ['date' => $cursor_date, 'product_id' => $cursor_product] : null;
     11933            if ($rows) { $last = end($rows); $next = ['date' => (string) $last['view_date'], 'product_id' => absint($last['product_id'])]; }
     11934            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     11935        }
     11936
     11937        public function normalize_record($origin_key, $record) {
     11938            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; }
     11939            $product_id = isset($record['product_id']) ? absint($record['product_id']) : 0;
     11940            $date = isset($record['view_date']) ? (string) $record['view_date'] : '';
     11941            $views_raw = $record['views'] ?? null;
     11942            if (!$product_id || !preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; }
     11943            $parsed = DateTime::createFromFormat('!Y-m-d', $date);
     11944            $errors = DateTime::getLastErrors();
     11945            if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; }
     11946            $views = (int) $views_raw;
     11947            $period_start = $date . ' 00:00:00';
     11948            $period_end = $date . ' 23:59:59';
     11949            return [
     11950                'product_id' => $product_id,
     11951                'source_record_key' => 'koko_analytics_' . $product_id . '_' . $date,
     11952                'views' => $views,
     11953                'record_type' => 'aggregate',
     11954                'period_start' => $period_start,
     11955                'period_end' => $period_end,
     11956                'cursor' => ['date' => $date, 'product_id' => $product_id],
     11957            ];
     11958        }
     11959    }
     11960}
     11961
     11962add_filter('statpl_historical_view_adapters', static function ($adapters) {
     11963    if (!is_array($adapters)) { return $adapters; }
     11964    $adapter = new STATPL_Koko_Analytics_Historical_Adapter();
     11965    if ($adapter->should_register_adapter()) { $adapters[] = $adapter; }
     11966    return $adapters;
     11967}, 35);
     11968
     11969/**
     11970 * Burst Statistics adapter.
     11971 *
     11972 * Imports raw product pageview rows from burst_statistics. Unlike aggregate
     11973 * providers, Burst stores a WordPress post ID and a Unix timestamp per hit, so
     11974 * product resolution is direct and each source row has a stable ID. Visitor
     11975 * identifiers and session data are intentionally not read or imported.
     11976 */
     11977if (!class_exists('STATPL_Burst_Statistics_Historical_Adapter')) {
     11978    final class STATPL_Burst_Statistics_Historical_Adapter implements STATPL_Historical_View_Adapter {
     11979        const ADAPTER_KEY = 'burst_statistics';
     11980        const SOURCE_KEY = 'burst_statistics';
     11981        const ADAPTER_VERSION = '1.0.0';
     11982        const ORIGIN_KEY = 'product_pageview_events';
     11983
     11984        public function get_adapter_key() { return self::ADAPTER_KEY; }
     11985        public function get_source_key() { return self::SOURCE_KEY; }
     11986        public function get_display_name() { return 'Burst Statistics'; }
     11987        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     11988
     11989        private function table_name() {
     11990            global $wpdb;
     11991            return $wpdb->prefix . 'burst_statistics';
     11992        }
     11993
     11994        private function quoted_table_name() {
     11995            $table = $this->table_name();
     11996            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     11997                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     11998            }
     11999            return '`' . $table . '`';
     12000        }
     12001
     12002        private function inspect_table() {
     12003            global $wpdb;
     12004            $table = $this->table_name();
     12005            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     12006            if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     12007            if ($exists !== $table) { return ['status' => 'missing_table']; }
     12008// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12009            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quoted_table_name(), ARRAY_A);
     12010            if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     12011            $names = [];
     12012            foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } }
     12013            if (array_diff(['id', 'page_id', 'page_type', 'time'], $names)) { return ['status' => 'incompatible_schema']; }
     12014            return ['status' => 'available'];
     12015        }
     12016
     12017        public function should_register_adapter() {
     12018            global $wpdb;
     12019            $inspection = $this->inspect_table();
     12020            if ($inspection['status'] !== 'missing_table') { return true; }
     12021            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     12022            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     12023                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     12024            }
     12025            $registry_table = '`' . $registry_name . '`';
     12026            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     12027            if (!empty($wpdb->last_error)) { return true; }
     12028            if ($registry_exists !== $registry_name) { return false; }
     12029            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     12030// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12031            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     12032            return !empty($wpdb->last_error) || !empty($source_id);
     12033        }
     12034
     12035        public function discover_origins() {
     12036            $inspection = $this->inspect_table();
     12037            if ($inspection['status'] === 'missing_table') { return []; }
     12038            return [[
     12039                'origin_key' => self::ORIGIN_KEY,
     12040                'capabilities' => ['product_views' => true, 'granularity' => 'event', 'timestamp_precision' => 'seconds', 'product_id_direct' => true],
     12041                'detection_status' => $inspection['status'],
     12042            ]];
     12043        }
     12044
     12045        public function get_origin_snapshot($origin_key) {
     12046            global $wpdb;
     12047            if ($origin_key !== self::ORIGIN_KEY) { return []; }
     12048            if ($this->inspect_table()['status'] !== 'available') {
     12049                throw new RuntimeException('Burst Statistics table is unavailable or incompatible.');
     12050            }
     12051            $posts_table_name = $wpdb->posts;
     12052            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     12053                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     12054            }
     12055            $posts_table = '`' . $posts_table_name . '`';
     12056// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12057            $max_id = statpl_db_get_var(statpl_db_prepare(
     12058                'SELECT MAX(s.ID)
     12059                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s
     12060                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.page_id AND p.post_type = %s
     12061                 WHERE s.page_id > 0 AND s.page_type = %s',
     12062                'product', 'product'
     12063            ));
     12064            if (!empty($wpdb->last_error)) {
     12065                throw new RuntimeException('Burst Statistics snapshot query failed.');
     12066            }
     12067            return [
     12068                'boundary' => ['max_id' => max(0, (int) $max_id)],
     12069                'initial_checkpoint' => null,
     12070                'captured_at' => current_time('mysql'),
     12071            ];
     12072        }
     12073
     12074        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     12075            global $wpdb;
     12076            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     12077                throw new RuntimeException('Invalid Burst Statistics origin or snapshot.');
     12078            }
     12079            if ($this->inspect_table()['status'] !== 'available') {
     12080                throw new RuntimeException('Burst Statistics table became unavailable or incompatible.');
     12081            }
     12082            $max_id = isset($snapshot['boundary']['max_id']) ? absint($snapshot['boundary']['max_id']) : -1;
     12083            if ($max_id < 0) { throw new RuntimeException('Invalid Burst Statistics snapshot boundary.'); }
     12084            $cursor_id = 0;
     12085            if (is_array($checkpoint) && isset($checkpoint['cursor'])) {
     12086                $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']);
     12087            } elseif (is_numeric($checkpoint)) { $cursor_id = absint($checkpoint); }
     12088            $limit = max(1, min(100, absint($limit)));
     12089            $posts_table_name = $wpdb->posts;
     12090            if (!is_string($posts_table_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $posts_table_name)) {
     12091                throw new InvalidArgumentException('Invalid WordPress posts table identifier.');
     12092            }
     12093            $posts_table = '`' . $posts_table_name . '`';
     12094// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12095            $rows = statpl_db_get_results(statpl_db_prepare(
     12096                'SELECT s.ID AS id, s.page_id, s.time
     12097                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quoted_table_name() . ' AS s
     12098                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = s.page_id AND p.post_type = %s
     12099                 WHERE s.page_type = %s AND s.page_id > 0 AND s.ID > %d AND s.ID <= %d
     12100                 ORDER BY s.ID ASC
     12101                 LIMIT %d',
     12102                'product', 'product', $cursor_id, $max_id, $limit + 1
     12103            ), ARRAY_A);
     12104            if (!empty($wpdb->last_error) || !is_array($rows)) {
     12105                throw new RuntimeException('Burst Statistics batch query failed.');
     12106            }
     12107            $has_more = count($rows) > $limit;
     12108            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     12109            $next = $cursor_id;
     12110            if ($rows) { $last = end($rows); $next = absint($last['id']); }
     12111            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     12112        }
     12113
     12114        public function normalize_record($origin_key, $record) {
     12115            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; }
     12116            $record_id = isset($record['id']) ? absint($record['id']) : 0;
     12117            $product_id = isset($record['page_id']) ? absint($record['page_id']) : 0;
     12118            $timestamp = isset($record['time']) ? absint($record['time']) : 0;
     12119            if (!$record_id || !$product_id || !$timestamp || !function_exists('wp_date')) { return null; }
     12120            $datetime = wp_date('Y-m-d H:i:s', $timestamp);
     12121            if (!is_string($datetime) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $datetime)) { return null; }
     12122            return [
     12123                'product_id' => $product_id,
     12124                'source_record_key' => 'burst_statistic_' . $record_id,
     12125                'views' => 1,
     12126                'record_type' => 'event',
     12127                'period_start' => $datetime,
     12128                'period_end' => $datetime,
     12129                'cursor' => $record_id,
     12130            ];
     12131        }
     12132
     12133        public function get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event) {
     12134            return [
     12135                'source' => 'burst_statistics',
     12136                'timestamp_precision' => 'seconds',
     12137                'overlap_assessment' => 'possible_but_not_proven',
     12138                'automatic_suppression' => false,
     12139                'reason' => 'Burst supplies an exact local event timestamp and product ID, but other providers do not share its stable pageview ID or visitor identity.',
     12140            ];
     12141        }
     12142    }
     12143}
     12144
     12145add_filter('statpl_historical_view_adapters', static function ($adapters) {
     12146    if (!is_array($adapters)) { return $adapters; }
     12147    $adapter = new STATPL_Burst_Statistics_Historical_Adapter();
     12148    if ($adapter->should_register_adapter()) { $adapters[] = $adapter; }
     12149    return $adapters;
     12150}, 36);
     12151
     12152/**
     12153 * Independent Analytics adapter.
     12154 *
     12155 * Imports one row per view from independent_analytics_views and resolves the
     12156 * resource through independent_analytics_resources.singular_id/post_type.
     12157 * Independent Analytics writes viewed_at in UTC. Only the timestamp and the
     12158 * product ID are imported; visitor/session identifiers are intentionally not
     12159 * accessed.
     12160 */
     12161if (!class_exists('STATPL_Independent_Analytics_Historical_Adapter')) {
     12162    final class STATPL_Independent_Analytics_Historical_Adapter implements STATPL_Historical_View_Adapter {
     12163        const ADAPTER_KEY = 'independent_analytics';
     12164        const SOURCE_KEY = 'independent_analytics';
     12165        const ADAPTER_VERSION = '1.0.0';
     12166        const ORIGIN_KEY = 'product_view_events';
     12167
     12168        public function get_adapter_key() { return self::ADAPTER_KEY; }
     12169        public function get_source_key() { return self::SOURCE_KEY; }
     12170        public function get_display_name() { return 'Independent Analytics'; }
     12171        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     12172
     12173        private function views_table() {
     12174            global $wpdb;
     12175            return $wpdb->prefix . 'independent_analytics_views';
     12176        }
     12177
     12178        private function resources_table() {
     12179            global $wpdb;
     12180            return $wpdb->prefix . 'independent_analytics_resources';
     12181        }
     12182
     12183        private function quote_table($table) {
     12184            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     12185                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     12186            }
     12187            return '`' . $table . '`';
     12188        }
     12189
     12190        private function inspect_tables() {
     12191            global $wpdb;
     12192            $found_any = false;
     12193            $missing_any = false;
     12194            foreach ([$this->views_table() => ['id', 'resource_id', 'viewed_at'], $this->resources_table() => ['id', 'singular_id', 'post_type']] as $table => $required) {
     12195                $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     12196                if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     12197                if ($exists !== $table) {
     12198                    $missing_any = true;
     12199                    continue;
     12200                }
     12201                $found_any = true;
     12202// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12203                $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quote_table($table), ARRAY_A);
     12204                if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     12205                $names = [];
     12206                foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } }
     12207                if (array_diff($required, $names)) { return ['status' => 'incompatible_schema']; }
     12208            }
     12209            if (!$found_any) { return ['status' => 'missing_table']; }
     12210            if ($missing_any) { return ['status' => 'incompatible_schema']; }
     12211            return ['status' => 'available'];
     12212        }
     12213
     12214        public function should_register_adapter() {
     12215            global $wpdb;
     12216            $inspection = $this->inspect_tables();
     12217            if ($inspection['status'] !== 'missing_table') { return true; }
     12218            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     12219            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     12220                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     12221            }
     12222            $registry_table = '`' . $registry_name . '`';
     12223            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     12224            if (!empty($wpdb->last_error)) { return true; }
     12225            if ($registry_exists !== $registry_name) { return false; }
     12226            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     12227// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12228            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     12229            return !empty($wpdb->last_error) || !empty($source_id);
     12230        }
     12231
     12232        public function discover_origins() {
     12233            $inspection = $this->inspect_tables();
     12234            if ($inspection['status'] === 'missing_table') { return []; }
     12235            return [[
     12236                'origin_key' => self::ORIGIN_KEY,
     12237                'capabilities' => ['product_views' => true, 'granularity' => 'event', 'timestamp_precision' => 'seconds', 'product_id_direct' => true],
     12238                'detection_status' => $inspection['status'],
     12239            ]];
     12240        }
     12241
     12242        public function get_origin_snapshot($origin_key) {
     12243            global $wpdb;
     12244            if ($origin_key !== self::ORIGIN_KEY) { return []; }
     12245            if ($this->inspect_tables()['status'] !== 'available') {
     12246                throw new RuntimeException('Independent Analytics tables are unavailable or incompatible.');
     12247            }
     12248            $posts_table = $this->quote_table($wpdb->posts);
     12249// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12250            $max_id = statpl_db_get_var(statpl_db_prepare(
     12251                'SELECT MAX(v.id)
     12252                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->views_table()) . ' AS v
     12253                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->resources_table()) . ' AS r ON r.id = v.resource_id
     12254                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = r.singular_id AND p.post_type = %s
     12255                 WHERE r.post_type = %s AND r.singular_id > 0',
     12256                'product', 'product'
     12257            ));
     12258            if (!empty($wpdb->last_error)) {
     12259                throw new RuntimeException('Independent Analytics snapshot query failed.');
     12260            }
     12261            return [
     12262                'boundary' => ['max_id' => max(0, (int) $max_id)],
     12263                'initial_checkpoint' => null,
     12264                'captured_at' => current_time('mysql'),
     12265            ];
     12266        }
     12267
     12268        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     12269            global $wpdb;
     12270            if ($origin_key !== self::ORIGIN_KEY || !is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     12271                throw new RuntimeException('Invalid Independent Analytics origin or snapshot.');
     12272            }
     12273            if ($this->inspect_tables()['status'] !== 'available') {
     12274                throw new RuntimeException('Independent Analytics tables became unavailable or incompatible.');
     12275            }
     12276            $max_id = isset($snapshot['boundary']['max_id']) ? absint($snapshot['boundary']['max_id']) : -1;
     12277            if ($max_id < 0) { throw new RuntimeException('Invalid Independent Analytics snapshot boundary.'); }
     12278            $cursor_id = 0;
     12279            if (is_array($checkpoint) && isset($checkpoint['cursor'])) {
     12280                $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']);
     12281            } elseif (is_numeric($checkpoint)) { $cursor_id = absint($checkpoint); }
     12282            $limit = max(1, min(100, absint($limit)));
     12283            $posts_table = $this->quote_table($wpdb->posts);
     12284// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12285            $rows = statpl_db_get_results(statpl_db_prepare(
     12286                'SELECT v.id, r.singular_id AS product_id, v.viewed_at
     12287                 FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->views_table()) . ' AS v
     12288                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->resources_table()) . ' AS r ON r.id = v.resource_id
     12289                 INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = r.singular_id AND p.post_type = %s
     12290                 WHERE r.post_type = %s AND r.singular_id > 0 AND v.id > %d AND v.id <= %d
     12291                 ORDER BY v.id ASC
     12292                 LIMIT %d',
     12293                'product', 'product', $cursor_id, $max_id, $limit + 1
     12294            ), ARRAY_A);
     12295            if (!empty($wpdb->last_error) || !is_array($rows)) {
     12296                throw new RuntimeException('Independent Analytics batch query failed.');
     12297            }
     12298            $has_more = count($rows) > $limit;
     12299            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     12300            $next = $cursor_id;
     12301            if ($rows) { $last = end($rows); $next = absint($last['id']); }
     12302            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     12303        }
     12304
     12305        public function normalize_record($origin_key, $record) {
     12306            if ($origin_key !== self::ORIGIN_KEY || !is_array($record)) { return null; }
     12307            $record_id = isset($record['id']) ? absint($record['id']) : 0;
     12308            $product_id = isset($record['product_id']) ? absint($record['product_id']) : 0;
     12309            $viewed_at = isset($record['viewed_at']) ? trim((string) $record['viewed_at']) : '';
     12310            if (!$record_id || !$product_id || $viewed_at === '') { return null; }
     12311            $timestamp = strtotime(str_replace('T', ' ', $viewed_at) . ' UTC');
     12312            if (!$timestamp || !function_exists('wp_date')) { return null; }
     12313            $datetime = wp_date('Y-m-d H:i:s', $timestamp);
     12314            if (!is_string($datetime) || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $datetime)) { return null; }
     12315            return [
     12316                'product_id' => $product_id,
     12317                'source_record_key' => 'independent_analytics_view_' . $record_id,
     12318                'views' => 1,
     12319                'record_type' => 'event',
     12320                'period_start' => $datetime,
     12321                'period_end' => $datetime,
     12322                'cursor' => $record_id,
     12323            ];
     12324        }
     12325
     12326        public function get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event) {
     12327            return [
     12328                'source' => 'independent_analytics',
     12329                'timestamp_precision' => 'seconds',
     12330                'overlap_assessment' => 'possible_but_not_proven',
     12331                'automatic_suppression' => false,
     12332                'reason' => 'Independent Analytics provides a per-view timestamp and product ID, but no stable event identifier shared with other providers.',
     12333            ];
     12334        }
     12335    }
     12336}
     12337
     12338add_filter('statpl_historical_view_adapters', static function ($adapters) {
     12339    if (!is_array($adapters)) { return $adapters; }
     12340    $adapter = new STATPL_Independent_Analytics_Historical_Adapter();
     12341    if ($adapter->should_register_adapter()) { $adapters[] = $adapter; }
     12342    return $adapters;
     12343}, 37);
     12344
     12345/**
     12346 * PulseViews adapter.
     12347 *
     12348 * Uses disjoint date windows: completed daily aggregates through two days ago,
     12349 * then non-bot raw events after that boundary. Weekly/monthly rollups and the
     12350 * lifetime totals cache are never imported, preventing overlap. PulseViews
     12351 * prunes raw events after 90 days but retains its daily aggregate history.
     12352 */
     12353if (!class_exists('STATPL_PulseViews_Historical_Adapter')) {
     12354    final class STATPL_PulseViews_Historical_Adapter implements STATPL_Historical_View_Adapter {
     12355        const ADAPTER_KEY = 'pulseviews';
     12356        const SOURCE_KEY = 'pulseviews';
     12357        const ADAPTER_VERSION = '1.0.0';
     12358        const DAILY_ORIGIN = 'daily_aggregates';
     12359        const RAW_ORIGIN = 'recent_raw_views';
     12360        private $snapshot_through_date = null;
     12361        private $snapshot_captured_at = null;
     12362
     12363        private function get_snapshot_through_date() {
     12364            if ($this->snapshot_through_date === null) {
     12365                $this->snapshot_through_date = current_datetime()->modify('-2 days')->format('Y-m-d');
     12366            }
     12367            return $this->snapshot_through_date;
     12368        }
     12369
     12370        private function get_snapshot_captured_at() {
     12371            if ($this->snapshot_captured_at === null) {
     12372                $this->snapshot_captured_at = current_time('mysql');
     12373            }
     12374            return $this->snapshot_captured_at;
     12375        }
     12376
     12377        public function get_adapter_key() { return self::ADAPTER_KEY; }
     12378        public function get_source_key() { return self::SOURCE_KEY; }
     12379        public function get_display_name() { return 'PulseViews'; }
     12380        public function get_adapter_version() { return self::ADAPTER_VERSION; }
     12381
     12382        private function table_name($kind) {
     12383            global $wpdb;
     12384            if (!in_array($kind, ['daily', 'raw'], true)) {
     12385                throw new InvalidArgumentException('Unsupported PulseViews table kind.');
     12386            }
     12387            return $wpdb->prefix . ($kind === 'daily' ? 'pulseviews_aggregates' : 'pulseviews_views');
     12388        }
     12389
     12390        private function quote_table($table) {
     12391            if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]+$/D', $table)) {
     12392                throw new InvalidArgumentException('Invalid historical analytics table identifier.');
     12393            }
     12394            return '`' . $table . '`';
     12395        }
     12396
     12397        private function inspect_table($kind) {
     12398            global $wpdb;
     12399            $table = $this->table_name($kind);
     12400            $exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
     12401            if (!empty($wpdb->last_error)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     12402            if ($exists !== $table) { return ['status' => 'missing_table']; }
     12403// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12404            $columns = statpl_db_get_results('SHOW COLUMNS FROM ' . $this->quote_table($table), ARRAY_A);
     12405            if (!empty($wpdb->last_error) || !is_array($columns)) { return ['status' => 'database_error', 'error' => (string) $wpdb->last_error]; }
     12406            $names = [];
     12407            foreach ($columns as $column) { if (isset($column['Field'])) { $names[] = strtolower((string) $column['Field']); } }
     12408            $required = $kind === 'daily'
     12409                ? ['id', 'post_id', 'period_date', 'period_type', 'view_count']
     12410                : ['id', 'post_id', 'viewed_at', 'is_bot'];
     12411            if (array_diff($required, $names)) { return ['status' => 'incompatible_schema']; }
     12412            return ['status' => 'available'];
     12413        }
     12414
     12415        public function should_register_adapter() {
     12416            global $wpdb;
     12417            $daily = $this->inspect_table('daily');
     12418            $raw = $this->inspect_table('raw');
     12419            if ($daily['status'] !== 'missing_table' || $raw['status'] !== 'missing_table') { return true; }
     12420            $registry_name = $wpdb->prefix . 'statpl_view_sources';
     12421            if (!is_string($registry_name) || !preg_match('/^[A-Za-z0-9_]+$/D', $registry_name)) {
     12422                throw new InvalidArgumentException('Invalid Statora source registry table identifier.');
     12423            }
     12424            $registry_table = '`' . $registry_name . '`';
     12425            $registry_exists = statpl_db_get_var(statpl_db_prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($registry_name)));
     12426            if (!empty($wpdb->last_error)) { return true; }
     12427            if ($registry_exists !== $registry_name) { return false; }
     12428            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $registry_table is the quoted WordPress-prefixed Statora registry identifier; its raw name is validated against ^[A-Za-z0-9_]+$ before quoting, while source_key is a prepared value.
     12429// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12430            $source_id = statpl_db_get_var(statpl_db_prepare('SELECT id FROM ' . $registry_table . ' WHERE source_key = %s LIMIT 1', self::SOURCE_KEY));
     12431            return !empty($wpdb->last_error) || !empty($source_id);
     12432        }
     12433
     12434        public function discover_origins() {
     12435            $origins = [];
     12436            $daily = $this->inspect_table('daily');
     12437            if ($daily['status'] !== 'missing_table') {
     12438                $origins[] = [
     12439                    'origin_key' => self::DAILY_ORIGIN,
     12440                    'capabilities' => ['product_views' => true, 'granularity' => 'daily_aggregate', 'window' => 'through_two_days_ago'],
     12441                    'detection_status' => $daily['status'],
     12442                ];
     12443            }
     12444            $raw = $this->inspect_table('raw');
     12445            if ($raw['status'] !== 'missing_table') {
     12446                $origins[] = [
     12447                    'origin_key' => self::RAW_ORIGIN,
     12448                    'capabilities' => ['product_views' => true, 'granularity' => 'event', 'window' => 'after_daily_aggregate_boundary', 'bot_filtered' => true],
     12449                    'detection_status' => $raw['status'],
     12450                ];
     12451            }
     12452            return $origins;
     12453        }
     12454
     12455        public function get_origin_snapshot($origin_key) {
     12456            global $wpdb;
     12457            $through_date = $this->get_snapshot_through_date();
     12458            $posts_table = $this->quote_table($wpdb->posts);
     12459            if ($origin_key === self::DAILY_ORIGIN) {
     12460                if ($this->inspect_table('daily')['status'] !== 'available') {
     12461                    throw new RuntimeException('PulseViews daily aggregate table is unavailable or incompatible.');
     12462                }
     12463// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12464                $max_id = statpl_db_get_var(statpl_db_prepare(
     12465                    'SELECT MAX(a.id)
     12466                     FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->table_name('daily')) . ' AS a
     12467                     INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = a.post_id AND p.post_type = %s
     12468                     WHERE a.period_type = %s AND a.period_date <= %s',
     12469                    'product', 'day', $through_date
     12470                ));
     12471                if (!empty($wpdb->last_error)) { throw new RuntimeException('PulseViews daily snapshot query failed.'); }
     12472                return ['boundary' => ['max_id' => max(0, (int) $max_id), 'through_date' => $through_date], 'initial_checkpoint' => null, 'captured_at' => current_time('mysql')];
     12473            }
     12474            if ($origin_key === self::RAW_ORIGIN) {
     12475                if ($this->inspect_table('raw')['status'] !== 'available') {
     12476                    throw new RuntimeException('PulseViews raw views table is unavailable or incompatible.');
     12477                }
     12478                $after_datetime = $through_date . ' 23:59:59';
     12479                $captured_at = $this->get_snapshot_captured_at();
     12480// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12481                $max_id = statpl_db_get_var(statpl_db_prepare(
     12482                    'SELECT MAX(v.id)
     12483                     FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->table_name('raw')) . ' AS v
     12484                     INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = v.post_id AND p.post_type = %s
     12485                     WHERE v.is_bot = 0 AND v.viewed_at > %s AND v.viewed_at <= %s',
     12486                    'product', $after_datetime, $captured_at
     12487                ));
     12488                if (!empty($wpdb->last_error)) { throw new RuntimeException('PulseViews raw snapshot query failed.'); }
     12489                return ['boundary' => ['max_id' => max(0, (int) $max_id), 'after_datetime' => $after_datetime, 'captured_at' => $captured_at], 'initial_checkpoint' => null, 'captured_at' => $captured_at];
     12490            }
     12491            return [];
     12492        }
     12493
     12494        public function get_product_view_batch($origin_key, $checkpoint, $snapshot, $limit) {
     12495            global $wpdb;
     12496            if (!is_array($snapshot) || empty($snapshot['boundary']) || !is_array($snapshot['boundary'])) {
     12497                throw new RuntimeException('Invalid PulseViews snapshot.');
     12498            }
     12499            $cursor_id = 0;
     12500            if (is_array($checkpoint) && isset($checkpoint['cursor'])) { $cursor_id = $checkpoint['cursor'] === null ? 0 : absint($checkpoint['cursor']); }
     12501            elseif (is_numeric($checkpoint)) { $cursor_id = absint($checkpoint); }
     12502            $max_id = isset($snapshot['boundary']['max_id']) ? absint($snapshot['boundary']['max_id']) : -1;
     12503            if ($max_id < 0) { throw new RuntimeException('Invalid PulseViews snapshot boundary.'); }
     12504            $limit = max(1, min(100, absint($limit)));
     12505            $posts_table = $this->quote_table($wpdb->posts);
     12506            if ($origin_key === self::DAILY_ORIGIN) {
     12507                if ($this->inspect_table('daily')['status'] !== 'available') { throw new RuntimeException('PulseViews daily aggregate table became unavailable.'); }
     12508                $through_date = isset($snapshot['boundary']['through_date']) ? (string) $snapshot['boundary']['through_date'] : '';
     12509                $parsed = DateTime::createFromFormat('!Y-m-d', $through_date);
     12510                if (!$parsed || $parsed->format('Y-m-d') !== $through_date) { throw new RuntimeException('Invalid PulseViews daily boundary.'); }
     12511// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12512                $rows = statpl_db_get_results(statpl_db_prepare(
     12513                    'SELECT a.id, a.post_id, a.period_date, a.view_count
     12514                     FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->table_name('daily')) . ' AS a
     12515                     INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = a.post_id AND p.post_type = %s
     12516                     WHERE a.period_type = %s AND a.id > %d AND a.id <= %d AND a.period_date <= %s
     12517                     ORDER BY a.id ASC
     12518                     LIMIT %d',
     12519                    'product', 'day', $cursor_id, $max_id, $through_date, $limit + 1
     12520                ), ARRAY_A);
     12521            } elseif ($origin_key === self::RAW_ORIGIN) {
     12522                if ($this->inspect_table('raw')['status'] !== 'available') { throw new RuntimeException('PulseViews raw views table became unavailable.'); }
     12523                $after_datetime = isset($snapshot['boundary']['after_datetime']) ? (string) $snapshot['boundary']['after_datetime'] : '';
     12524                $captured_at = isset($snapshot['boundary']['captured_at']) ? (string) $snapshot['boundary']['captured_at'] : '';
     12525                if ($after_datetime === '' || $captured_at === '') { throw new RuntimeException('Invalid PulseViews raw boundary.'); }
     12526// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- This query is prepared at execution; dynamic identifiers/fragments are constrained by validated identifiers, fixed allowlists or generated placeholders, and runtime values use placeholders.
     12527                $rows = statpl_db_get_results(statpl_db_prepare(
     12528                    'SELECT v.id, v.post_id, v.viewed_at
     12529                     FROM ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $this->quote_table($this->table_name('raw')) . ' AS v
     12530                     INNER JOIN ' . /* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The flagged fragment is part of a prepared statement; dynamic identifiers/fragments are built from validated WordPress table names or fixed allowlists, while runtime values use placeholders. */ $posts_table . ' AS p ON p.ID = v.post_id AND p.post_type = %s
     12531                     WHERE v.is_bot = 0 AND v.id > %d AND v.id <= %d AND v.viewed_at > %s AND v.viewed_at <= %s
     12532                     ORDER BY v.id ASC
     12533                     LIMIT %d',
     12534                    'product', $cursor_id, $max_id, $after_datetime, $captured_at, $limit + 1
     12535                ), ARRAY_A);
     12536            } else {
     12537                throw new RuntimeException('Unknown PulseViews origin.');
     12538            }
     12539            if (!empty($wpdb->last_error) || !is_array($rows)) { throw new RuntimeException('PulseViews batch query failed.'); }
     12540            $has_more = count($rows) > $limit;
     12541            if ($has_more) { $rows = array_slice($rows, 0, $limit); }
     12542            $next = $cursor_id;
     12543            if ($rows) { $last = end($rows); $next = absint($last['id']); }
     12544            return ['rows' => array_values($rows), 'has_more' => $has_more, 'next_checkpoint' => $next, 'boundary_applied' => true];
     12545        }
     12546
     12547        public function normalize_record($origin_key, $record) {
     12548            if (!is_array($record)) { return null; }
     12549            $record_id = isset($record['id']) ? absint($record['id']) : 0;
     12550            $product_id = isset($record['post_id']) ? absint($record['post_id']) : 0;
     12551            if (!$record_id || !$product_id) { return null; }
     12552            if ($origin_key === self::DAILY_ORIGIN) {
     12553                $date = isset($record['period_date']) ? (string) $record['period_date'] : '';
     12554                $views_raw = $record['view_count'] ?? null;
     12555                if (!preg_match('/^(0|[1-9][0-9]*)$/D', (string) $views_raw)) { return null; }
     12556                $parsed = DateTime::createFromFormat('!Y-m-d', $date);
     12557                $errors = DateTime::getLastErrors();
     12558                if (!$parsed || $parsed->format('Y-m-d') !== $date || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; }
     12559                $views = (int) $views_raw;
     12560                $period_start = $date . ' 00:00:00';
     12561                $period_end = $date . ' 23:59:59';
     12562                return ['product_id' => $product_id, 'source_record_key' => 'pulseviews_day_' . $product_id . '_' . $date, 'views' => $views, 'record_type' => 'aggregate', 'period_start' => $period_start, 'period_end' => $period_end, 'cursor' => $record_id];
     12563            }
     12564            if ($origin_key === self::RAW_ORIGIN) {
     12565                $viewed_at = isset($record['viewed_at']) ? (string) $record['viewed_at'] : '';
     12566                $parsed = DateTime::createFromFormat('!Y-m-d H:i:s', $viewed_at);
     12567                $errors = DateTime::getLastErrors();
     12568                if (!$parsed || $parsed->format('Y-m-d H:i:s') !== $viewed_at || ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count'])))) { return null; }
     12569                return ['product_id' => $product_id, 'source_record_key' => 'pulseviews_view_' . $record_id, 'views' => 1, 'record_type' => 'event', 'period_start' => $viewed_at, 'period_end' => $viewed_at, 'cursor' => $record_id];
     12570            }
     12571            return null;
     12572        }
     12573
     12574        public function get_reconciliation_evidence($origin_key, $normalized_record, $candidate_event) {
     12575            return [
     12576                'source' => 'pulseviews',
     12577                'origin' => $origin_key,
     12578                'overlap_assessment' => 'possible_but_not_proven',
     12579                'automatic_suppression' => false,
     12580                'reason' => 'PulseViews event rows have local timestamps but no stable cross-provider event identifier; daily aggregates are matched only on exact product/date/count dimensions.',
     12581            ];
     12582        }
     12583    }
     12584}
     12585
     12586add_filter('statpl_historical_view_adapters', static function ($adapters) {
     12587    if (!is_array($adapters)) { return $adapters; }
     12588    $adapter = new STATPL_PulseViews_Historical_Adapter();
     12589    if ($adapter->should_register_adapter()) { $adapters[] = $adapter; }
     12590    return $adapters;
     12591}, 38);
Note: See TracChangeset for help on using the changeset viewer.