<?php
/**
 * Main TrustLens class.
 *
 * @package TrustLens
 * @since   1.0.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * TrustLens main class.
 *
 * @since 1.0.0
 */
final class TrustLens {

	/**
	 * Single instance of the class.
	 *
	 * @var TrustLens|null
	 */
	private static ?TrustLens $instance = null;

	/**
	 * Registered modules.
	 *
	 * @var array
	 */
	private array $modules = array();

	/**
	 * Get the single instance.
	 *
	 * @since 1.0.0
	 * @return TrustLens
	 */
	public static function instance(): TrustLens {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Constructor.
	 *
	 * @since 1.0.0
	 */
	private function __construct() {
		$this->load_dependencies();
		$this->init_hooks();
	}

	/**
	 * Prevent cloning.
	 *
	 * @since 1.0.0
	 */
	private function __clone() {}

	/**
	 * Prevent unserializing.
	 *
	 * @since 1.0.0
	 * @throws \Exception Always throws.
	 */
	public function __wakeup() {
		throw new \Exception( 'Cannot unserialize singleton' );
	}

	/**
	 * Load required files.
	 *
	 * @since 1.0.0
	 */
	private function load_dependencies(): void {
		// Gate primitive (new in 1.3.0) — must load before any modules that register rules.
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/gate/class-gate-context.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/gate/class-gate-block.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/gate/class-request-gate.php';

		// Fingerprint service (new in 1.3.0).
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/fingerprint/class-fingerprint-service.php';

		// Card-testing defense support services (new in 1.4.0). These are
		// standalone classes — not modules — so they load before the module
		// base class. The module file itself (class-module-card-testing.php)
		// extends TrustLens_Module and must load AFTER includes/class-module.php.
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/class-velocity-recorder.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/class-decline-hooks.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/class-velocity-detector.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/class-lockdown-state.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/class-panic-button-ajax.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/class-retention-cron.php';

		// Data layer.
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-customer-repository.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-dispute-repository.php';

		// Modules.
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-module.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/modules/class-module-returns.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/modules/class-module-orders.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/modules/class-module-coupons.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/modules/class-module-categories.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/modules/class-module-shipping-anomalies.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/modules/class-module-card-testing.php';

		// Services.
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-score-calculator.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-historical-sync.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-checkout-blocker.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-notifications.php';
		if ( wstl_is_pro() && file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/class-notifications-pro.php' ) ) {
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-notifications-pro.php';
		}
		if ( wstl_is_pro() && file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/class-payment-method-controls.php' ) ) {
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-payment-method-controls.php';
		}
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-order-trust-column.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-data-export.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-rest-api.php';

		// Phase 3: Advanced features.
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-analytics.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-linked-accounts.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-bulk-operations.php';
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-review-events.php';

		// Card brand capture + Chargeback tracking (Free — see docs/superpowers/specs
		// for the freemium split). The dedicated monitor page, trend chart, alert
		// cron, and per-brand breakdown remain Pro and are gated at the use site.
		require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-card-brand-detector.php';
		if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/modules/class-module-chargebacks.php' ) ) {
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/modules/class-module-chargebacks.php';
		}
		if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/class-chargeback-monitor.php' ) ) {
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-chargeback-monitor.php';
		}

		// Automation engine + retention cron: load whenever the Pro build ships
		// them. Freemius strips these files from the free build, so file_exists is
		// the correct build-level gate — and loading them independent of the LIVE
		// license lets the always-on webhook consumer drain queued deliveries and
		// the retention cron keep pruning logs after a Pro→Free lapse. Rule firing
		// and saving stay license-gated by their callers, so this only affects
		// in-flight async work, never new automation.
		if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/class-automation.php' ) ) {
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-automation.php';
		}
		if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/class-automation-retention-cron.php' ) ) {
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-automation-retention-cron.php';
		}

		// Pro features (files stripped from free version by Freemius deployment).
		if ( wstl_is_pro() ) {
			if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/class-automation-log-query.php' ) ) {
				require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-automation-log-query.php';
			}
			if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/class-webhooks.php' ) ) {
				require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-webhooks.php';
			}
			if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/class-scheduled-reports.php' ) ) {
				require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-scheduled-reports.php';
			}
			if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/class-dispute-report.php' ) ) {
				require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-dispute-report.php';
			}
			if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/class-dispute-evidence.php' ) ) {
				require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-dispute-evidence.php';
			}
			if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/class-dispute-attestation.php' ) ) {
				require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-dispute-attestation.php';
				add_action( 'trustlens/attestation_retry', array( 'TrustLens_Dispute_Attestation', 'retry_pending' ) );
			}
			if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/class-qr-code.php' ) ) {
				require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-qr-code.php';
			}
		}

		// Card-testing Pro (1.5.0+). Files are @fs_premium_only and
		// stripped from free builds; file_exists guards make the free
		// path a silent no-op.
		if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/pro/class-allowlist-repository.php' ) ) {
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/pro/class-allowlist-repository.php';
		}
		if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/pro/class-allowlist-rule.php' ) ) {
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/pro/class-allowlist-rule.php';
		}
		if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/pro/class-geo-diversity-checker.php' ) ) {
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/pro/class-geo-diversity-checker.php';
		}
		if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/pro/class-auto-escalation.php' ) ) {
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/pro/class-auto-escalation.php';
		}
		if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/pro/class-history-query.php' ) ) {
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/pro/class-history-query.php';
		}
		if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/pro/class-alert-dispatcher.php' ) ) {
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/pro/class-alert-dispatcher.php';
		}
		if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/pro/class-pro-bootstrap.php' ) ) {
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/card-testing/pro/class-pro-bootstrap.php';
		}

		// Store Trust Network (Pro, multi-site, 1.3.5+). Files are @fs_premium_only
		// and stripped from free builds; the file_exists guard makes free a no-op.
		if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/network/class-network-crypto.php' ) ) {
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/network/class-network-crypto.php';
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/network/class-network-license.php';
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/network/class-network-flag-repository.php';
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/network/class-network-controller.php';
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/network/class-network-sync.php';
			require_once TRUSTLENS_PLUGIN_DIR . 'includes/modules/class-module-store-network.php';
			TrustLens_Network_Controller::instance();
			add_action( TrustLens_Network_Sync::CRON_HOOK, array( new TrustLens_Network_Sync(), 'run' ) );
		}

		// Admin.
		if ( is_admin() ) {
			require_once TRUSTLENS_PLUGIN_DIR . 'admin/class-admin-pages.php';
			require_once TRUSTLENS_PLUGIN_DIR . 'admin/class-admin-settings.php';
			require_once TRUSTLENS_PLUGIN_DIR . 'admin/class-admin-notices.php';
			require_once TRUSTLENS_PLUGIN_DIR . 'admin/class-admin-ajax.php';
			require_once TRUSTLENS_PLUGIN_DIR . 'admin/class-admin.php';
			require_once TRUSTLENS_PLUGIN_DIR . 'admin/class-admin-card-testing-page.php';
			require_once TRUSTLENS_PLUGIN_DIR . 'admin/class-admin-header.php';

			// Test data seeder (stripped from production builds).
			if ( file_exists( TRUSTLENS_PLUGIN_DIR . 'includes/class-test-data-seeder.php' ) ) {
				require_once TRUSTLENS_PLUGIN_DIR . 'includes/class-test-data-seeder.php';
			}
		}
	}

	/**
	 * Initialize hooks.
	 *
	 * @since 1.0.0
	 */
	private function init_hooks(): void {
		// Request gate must register hooks before modules so rules can be appended.
		TrustLens_Request_Gate::instance()->register_hooks();

		// Fingerprint JS on checkout / cart pages.
		add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_fingerprint_assets' ) );
		add_filter( 'script_loader_tag', array( $this, 'mark_fingerprint_pro_attribute' ), 10, 2 );

		// Initialize components.
		add_action( 'init', array( $this, 'init_modules' ) );
		add_action( 'init', array( $this, 'ensure_notification_schedules' ) );

		// When a notification toggle changes, clear the reconciliation throttle so
		// the next request schedules/unschedules the cron immediately rather than
		// waiting up to an hour for the throttle to expire.
		add_action( 'update_option_trustlens_enable_notifications', array( $this, 'invalidate_schedule_throttle' ) );
		add_action( 'update_option_trustlens_notify_weekly_summary', array( $this, 'invalidate_schedule_throttle' ) );
		add_action( 'update_option_trustlens_enable_daily_digest',   array( $this, 'invalidate_schedule_throttle' ) );
		add_action( 'update_option_trustlens_notify_monthly_report', array( $this, 'invalidate_schedule_throttle' ) );

		// Register Action Scheduler hooks.
		add_action( 'trustlens/calculate_score', array( $this, 'process_score_calculation' ) );

		// Clean up old completed ActionScheduler actions daily.
		add_action( 'trustlens/daily_cleanup', array( $this, 'cleanup_completed_actions' ) );

		// Bound the Trust Score Trends snapshot history so trustlens_signals does
		// not grow without limit (one 'score_update' row per customer per day).
		add_action( 'trustlens/daily_cleanup', array( 'TrustLens_Score_Calculator', 'prune_score_history' ) );

		// Initialize historical sync.
		TrustLens_Historical_Sync::instance();

		// Initialize notifications.
		TrustLens_Notifications::instance();
		if ( class_exists( 'TrustLens_Notifications_Pro' ) && wstl_can_use_pro() ) {
			TrustLens_Notifications_Pro::instance();
		}
		if ( class_exists( 'TrustLens_Payment_Method_Controls' ) && wstl_can_use_pro() ) {
			TrustLens_Payment_Method_Controls::instance();
		}

		// Initialize data export.
		TrustLens_Data_Export::instance();

		// Initialize REST API.
		TrustLens_REST_API::instance();

		// Phase 3: Initialize advanced features.
		TrustLens_Linked_Accounts::instance();

		// Card brand capture (Free — always registers hooks).
		if ( class_exists( 'TrustLens_Card_Brand_Detector' ) ) {
			$card_brand_detector = new TrustLens_Card_Brand_Detector();
			$card_brand_detector->register_hooks();
		}

		// Review-prompt success signals. Always-on: the "attack blocked" event
		// fires on the frontend, so this cannot be gated behind is_admin().
		if ( class_exists( 'TrustLens_Review_Events' ) ) {
			( new TrustLens_Review_Events() )->register_hooks();
		}

		// Pro features (classes stripped from free version by Freemius deployment).
		// Automation is split so a Pro→Free license lapse can't orphan in-flight
		// async work: the singleton's constructor registers the always-on webhook
		// consumer and the retention cron keeps pruning, both independent of the
		// license, while only rule FIRING (init_hooks) is Pro-gated.
		if ( class_exists( 'TrustLens_Automation' ) ) {
			$automation = TrustLens_Automation::instance();
			if ( wstl_can_use_pro() ) {
				$automation->init_hooks();
			}
			if ( class_exists( 'TrustLens_Automation_Retention_Cron' ) ) {
				( new TrustLens_Automation_Retention_Cron() )->register_hooks();
			}
		}
		if ( class_exists( 'TrustLens_Webhooks' ) && wstl_can_use_pro() ) {
			TrustLens_Webhooks::instance();
		}
		if ( class_exists( 'TrustLens_Scheduled_Reports' ) && wstl_can_use_pro() ) {
			TrustLens_Scheduled_Reports::instance();
			add_action( 'init', array( 'TrustLens_Scheduled_Reports', 'maybe_schedule_reports' ) );
		}
		if ( class_exists( 'TrustLens_Dispute_Report' ) && wstl_can_use_pro() ) {
			TrustLens_Dispute_Report::instance();
		}
		if ( class_exists( 'TrustLens_Chargeback_Monitor' ) && wstl_can_use_pro() ) {
			$chargeback_monitor = new TrustLens_Chargeback_Monitor();
			add_action( TrustLens_Chargeback_Monitor::CRON_HOOK, array( $chargeback_monitor, 'check_and_alert' ) );
		}

		// Initialize checkout blocker (frontend).
		if ( ! is_admin() ) {
			TrustLens_Checkout_Blocker::instance();
		}

		// Initialize order trust column (admin UI + frontend meta storage).
		TrustLens_Order_Trust_Column::instance();

		// Initialize admin.
		if ( is_admin() ) {
			TrustLens_Admin::instance();

			// Test data seeder (stripped from production builds).
			if ( class_exists( 'TrustLens_Test_Data_Seeder' ) ) {
				TrustLens_Test_Data_Seeder::instance();
			}
		}

		// Card-testing admin page.
		if ( is_admin() ) {
			( new TrustLens_Admin_Card_Testing_Page() )->register_hooks();
			add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_card_testing_admin_assets' ) );

			// Persistent admin header — renders on every TrustLens plugin page.
			( new TrustLens_Admin_Header() )->register_hooks();
		}
	}

	/**
	 * Enqueue browser fingerprint JS on checkout and cart pages.
	 *
	 * @since 1.2.2
	 */
	public function enqueue_fingerprint_assets(): void {
		if ( ! function_exists( 'is_checkout' ) ) {
			return;
		}
		if ( ! is_checkout() && ! is_cart() ) {
			return;
		}

		wp_enqueue_script(
			'trustlens-fingerprint',
			TRUSTLENS_PLUGIN_URL . 'assets/js/fingerprint.js',
			array(),
			TRUSTLENS_VERSION,
			true
		);

		wp_enqueue_script(
			'trustlens-fingerprint-blocks',
			TRUSTLENS_PLUGIN_URL . 'assets/js/fingerprint-blocks.js',
			array( 'trustlens-fingerprint' ),
			TRUSTLENS_VERSION,
			true
		);
	}

	/**
	 * Add data-tl-pro-fingerprint="1" to the trustlens-fingerprint script
	 * tag when Pro is licensed AND card-testing is enabled. Opt-in switch
	 * the JS reads to include the Pro font-enumeration signal.
	 *
	 * @since 1.2.2
	 * @param string $tag    Full script tag HTML.
	 * @param string $handle Registered script handle.
	 * @return string
	 */
	public function mark_fingerprint_pro_attribute( string $tag, string $handle ): string {
		if ( 'trustlens-fingerprint' !== $handle ) {
			return $tag;
		}
		if ( ! function_exists( 'wstl_can_use_pro' ) || ! wstl_can_use_pro() ) {
			return $tag;
		}
		if ( ! get_option( 'trustlens_module_card_testing_enabled', false ) ) {
			return $tag;
		}
		return str_replace( '<script ', '<script data-tl-pro-fingerprint="1" ', $tag );
	}

	/**
	 * Initialize detection modules.
	 *
	 * @since 1.0.0
	 */
	public function init_modules(): void {
		// Register core modules (Free).
		$this->register_module( new TrustLens_Module_Returns() );
		$this->register_module( new TrustLens_Module_Orders() );
		$this->register_module( new TrustLens_Module_Coupons() );
		$this->register_module( new TrustLens_Module_Categories() );
		$this->register_module( new TrustLens_Module_Shipping_Anomalies() );
		$this->register_module( new TrustLens_Module_Card_Testing() );

		// Chargebacks is a Free module (basic dispute tracking + the blended
		// chargeback-ratio speedometer). Its Pro extras — the dedicated monitor
		// page, trend chart, alert cron, per-brand breakdown, and the dispute
		// evidence report — are gated at their use sites by wstl_can_use_pro().
		// The class_exists guard is defensive only; the file ships in Free.
		if ( class_exists( 'TrustLens_Module_Chargebacks' ) ) {
			$this->register_module( new TrustLens_Module_Chargebacks() );
		}

		// Store Trust Network (Pro, multi-site). Registered only when the Pro code is
		// present; its own is_enabled() gates on license + group key + opt-in toggle.
		if ( class_exists( 'TrustLens_Module_Store_Network' ) ) {
			$this->register_module( new TrustLens_Module_Store_Network() );
		}

		// Allow extensions to register modules.
		do_action( 'trustlens/register_modules', $this );

		// Initialize registered modules.
		foreach ( $this->modules as $module ) {
			if ( $module->is_enabled() ) {
				$module->register_hooks();
			}
		}

		// Backfill hooks must run even when the module is disabled.
		TrustLens_Module_Shipping_Anomalies::register_backfill_hooks();
	}

	/**
	 * Ensure notification cron events exist after license or settings changes.
	 *
	 * @since 1.1.5
	 * @return void
	 */
	/**
	 * Clear the schedule-reconciliation throttle so the next request re-runs the
	 * schedule check. Called from `update_option_*` hooks when a notification
	 * toggle changes.
	 *
	 * @since 1.2.3
	 */
	public function invalidate_schedule_throttle(): void {
		delete_transient( 'wstl_schedules_reconciled' );
	}

	public function ensure_notification_schedules(): void {
		// Throttle to once per hour. Previously this ran on every `init` (every
		// frontend page load), and the conditional `wp_clear_scheduled_hook()`
		// path writes to the wp_options table — so a high-traffic store with
		// notifications disabled was getting a needless DB write per request.
		// Settings changes call `invalidate_schedule_throttle()` so a fresh
		// reconciliation runs on the very next request after a toggle change.
		if ( false !== get_transient( 'wstl_schedules_reconciled' ) ) {
			return;
		}
		set_transient( 'wstl_schedules_reconciled', 1, HOUR_IN_SECONDS );

		$notifications_enabled = (bool) get_option( 'trustlens_enable_notifications', false );
		$weekly_enabled = $notifications_enabled && (bool) get_option( 'trustlens_notify_weekly_summary', true );
		$this->reconcile_recurring_event(
			'trustlens/weekly_summary',
			$weekly_enabled,
			strtotime( 'next monday 7:00am' ),
			'weekly'
		);

		if ( ! class_exists( 'TrustLens_Notifications_Pro' ) || ! wstl_can_use_pro() ) {
			$this->clear_recurring_event( 'trustlens/daily_digest' );
			$this->clear_recurring_event( 'trustlens/monthly_report' );
			return;
		}

		$daily_digest_enabled = $notifications_enabled && (bool) get_option( 'trustlens_enable_daily_digest', false );
		$this->reconcile_recurring_event(
			'trustlens/daily_digest',
			$daily_digest_enabled,
			strtotime( 'tomorrow 7:00am' ),
			'daily'
		);

		$monthly_report_enabled = $notifications_enabled && (bool) get_option( 'trustlens_notify_monthly_report', false );
		$this->reconcile_recurring_event(
			'trustlens/monthly_report',
			$monthly_report_enabled,
			strtotime( 'first day of next month 8:00am' ),
			'monthly'
		);
	}

	/**
	 * Ensure a recurring event exists only when enabled.
	 *
	 * @since 1.1.5
	 * @param string $hook      Cron hook.
	 * @param bool   $enabled   Whether the event should be scheduled.
	 * @param int    $timestamp First run timestamp.
	 * @param string $schedule  Cron schedule slug.
	 * @return void
	 */
	private function reconcile_recurring_event( string $hook, bool $enabled, int $timestamp, string $schedule ): void {
		if ( ! $enabled ) {
			$this->clear_recurring_event( $hook );
			return;
		}

		if ( ! wp_next_scheduled( $hook ) ) {
			wp_schedule_event( $timestamp, $schedule, $hook );
		}
	}

	/**
	 * Remove all scheduled instances of a recurring cron hook.
	 *
	 * @since 1.1.5
	 * @param string $hook Cron hook.
	 * @return void
	 */
	private function clear_recurring_event( string $hook ): void {
		wp_clear_scheduled_hook( $hook );
	}

	/**
	 * Register a module.
	 *
	 * @since 1.0.0
	 * @param TrustLens_Module $module Module instance.
	 */
	public function register_module( TrustLens_Module $module ): void {
		$this->modules[ $module->get_id() ] = $module;
	}

	/**
	 * Get all registered modules.
	 *
	 * @since 1.0.0
	 * @return array Registered modules.
	 */
	public function get_modules(): array {
		return $this->modules;
	}

	/**
	 * Get active modules.
	 *
	 * @since 1.0.0
	 * @return array Active modules.
	 */
	public function get_active_modules(): array {
		return array_filter( $this->modules, function( $module ) {
			return $module->is_enabled();
		} );
	}

	/**
	 * Process score calculation (called via Action Scheduler).
	 *
	 * @since 1.0.0
	 * @param string $email_hash Customer email hash.
	 */
	public function process_score_calculation( string $email_hash ): void {
		// Delegate to TrustLens_Score_Calculator::recalculate_score so the
		// save + fire(score_updated/segment_changed) logic lives in one place.
		// Previously this method had its own copy that could drift over time.
		( new TrustLens_Score_Calculator() )->recalculate_score( $email_hash );
	}

	/**
	 * Enqueue CSS and JS for the Card-Testing Defense admin page.
	 *
	 * @since 1.2.2
	 * @param string $hook Current admin page hook.
	 */
	public function enqueue_card_testing_admin_assets( string $hook ): void {
		if ( false === strpos( $hook, 'trustlens-card-testing' ) ) {
			return;
		}
		wp_enqueue_script(
			'trustlens-card-testing-admin',
			TRUSTLENS_PLUGIN_URL . 'assets/js/card-testing-admin.js',
			array( 'jquery' ),
			TRUSTLENS_VERSION,
			true
		);
		wp_enqueue_style(
			'trustlens-card-testing-admin',
			TRUSTLENS_PLUGIN_URL . 'assets/css/card-testing-admin.css',
			array(),
			TRUSTLENS_VERSION
		);

		if ( function_exists( 'wstl_can_use_pro' ) && wstl_can_use_pro() ) {
			// Chart.js — use whichever handle the plugin already registers.
			// Fall back to registering from CDN if no existing handle is found.
			$chart_handle = null;
			foreach ( array( 'chart-js', 'chartjs', 'tl-chart-js' ) as $candidate ) {
				if ( wp_script_is( $candidate, 'registered' ) ) {
					$chart_handle = $candidate;
					break;
				}
			}
			if ( null === $chart_handle ) {
				$chart_handle = 'trustlens-chart-js';
				wp_register_script(
					$chart_handle,
					TRUSTLENS_PLUGIN_URL . 'admin/vendor/chart-js/chart.umd.min.js',
					array(),
					'4.5.1',
					true
				);
			}
			wp_enqueue_script( $chart_handle );

			$chart_theme_path = TRUSTLENS_PLUGIN_DIR . 'admin/js/chart-theme.js';
			$chart_theme_ver  = file_exists( $chart_theme_path ) ? (string) filemtime( $chart_theme_path ) : TRUSTLENS_VERSION;
			wp_enqueue_script(
				'trustlens-chart-theme',
				TRUSTLENS_PLUGIN_URL . 'admin/js/chart-theme.js',
				array( $chart_handle ),
				$chart_theme_ver,
				true
			);

			wp_enqueue_script(
				'trustlens-card-testing-admin-pro',
				TRUSTLENS_PLUGIN_URL . 'assets/js/card-testing-admin-pro.js',
				array( $chart_handle, 'trustlens-chart-theme' ),
				TRUSTLENS_VERSION,
				true
			);
		}
	}

	/**
	 * Clean up old completed ActionScheduler actions for the trustlens group.
	 *
	 * Prevents the actionscheduler_actions table from growing indefinitely
	 * with completed score calculations, sync batches, and bulk operations.
	 *
	 * @since 1.1.6
	 * @return void
	 */
	public function cleanup_completed_actions(): void {
		global $wpdb;

		$table = $wpdb->prefix . 'actionscheduler_actions';

		// Check if ActionScheduler table exists.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from trusted $wpdb->prefix.
		$table_exists = $wpdb->get_var(
			$wpdb->prepare(
				'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = %s AND table_name = %s',
				DB_NAME,
				$table
			)
		);

		if ( ! $table_exists ) {
			return;
		}

		$cutoff = gmdate( 'Y-m-d H:i:s', strtotime( '-7 days' ) );

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from trusted $wpdb->prefix. Scheduled cleanup of old ActionScheduler rows.
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$table} WHERE `group_id` IN (
					SELECT group_id FROM {$wpdb->prefix}actionscheduler_groups WHERE slug = %s
				) AND status IN ( 'complete', 'failed', 'canceled' ) AND last_attempt_gmt < %s",
				'trustlens',
				$cutoff
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
	}
}
