# metasync/trunk/includes/class-metasync.php

Search Atlas SEO – OTTO AI SEO Automation for WordPress, version trunk. 1,753 lines.

- Page: https://pluginprobe.com/plugins/metasync/trunk/code/includes/class-metasync.php
- Raw: https://pluginprobe.com/plugins/metasync/trunk/raw/includes/class-metasync.php
- Modified: 2026-09-04T23:03:36+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/metasync/trunk/code/includes/class-metasync.php#L10-L20`.

```php
<?php
// If this file is called directly, abort.
if (!defined('ABSPATH')) {
	exit;
}


/**
 * The file that defines the core plugin class
 *
 * A class definition that includes attributes and functions used across both the
 * public-facing side of the site and the admin area.
 *
 * @link       https://searchatlas.com
 * @since      1.0.0
 *
 * @package    Metasync
 * @subpackage Metasync/includes
 */

/**
 * The core plugin class.
 *
 * This is used to define internationalization, admin-specific hooks, and
 * public-facing site hooks.
 *
 * Also maintains the unique identifier of this plugin as well as the current
 * version of the plugin.
 *
 * @since      1.0.0
 * @package    Metasync
 * @subpackage Metasync/includes
 * @author     Engineering Team <support@searchatlas.com>
 */
class Metasync
{

	/**
	 * The loader that's responsible for maintaining and registering all hooks that power
	 * the plugin.
	 *
	 * @since    1.0.0
	 * @access   protected
	 * @var      Metasync_Loader    $loader    Maintains and registers all hooks for the plugin.
	 */
	protected $loader;

	/**
	 * The unique identifier of this plugin.
	 *
	 * @since    1.0.0
	 * @access   protected
	 * @var      string    $plugin_name    The string used to uniquely identify this plugin.
	 */
	protected $plugin_name;

	/**
	 * The current version of the plugin.
	 *
	 * @since    1.0.0
	 * @access   protected
	 * @var      string    $version    The current version of the plugin.
	 */
	protected $version;

	protected $database;

	protected $db_redirection;

	protected $db_heartbeat_errors;



	public const option_name = "metasync_options";

	/**
	 * Dedicated option key for heartbeat throttle timestamps.
	 *
	 * Stored separately from the main options blob so writes to last_heart_beat
	 * and last_heartbeat_at do not race with concurrent settings writes during
	 * the 15-second wp_remote_post window in SyncCustomerParams.
	 */
	public const heartbeat_throttle_option = "metasync_heartbeat_throttle";

	/**
	 * Search Atlas Domain Constants
	 * Centralized constants for all Search Atlas service endpoints
	 */
	public const HOMEPAGE_DOMAIN = "https://searchatlas.com";
	public const DASHBOARD_DOMAIN = "https://dashboard.searchatlas.com";
	public const API_DOMAIN = "https://api.searchatlas.com";
	public const CA_API_DOMAIN = "https://ca.searchatlas.com";
	public const SUPPORT_EMAIL = "support@searchatlas.com";
	public const DOCUMENTATION_DOMAIN = "https://help.searchatlas.com";

	/**
	 * Storage prefix marking a Search Atlas API key value as encrypted at rest.
	 */
	private const API_KEY_ENC_PREFIX = 'enc_v1:';

	/**
	 * Request-scoped memo for the decrypted Search Atlas API key.
	 *
	 * null   = not yet loaded this request
	 * false  = load attempted but decryption failed (salts changed / corrupt)
	 * string = decrypted plaintext (may be '')
	 *
	 * Never written to a transient or the DB — exists only for the lifetime
	 * of the current request.
	 *
	 * @var string|false|null
	 */
	private static $memo_api_key = null;

	/**
	 * Define the core functionality of the plugin.
	 *
	 * Set the plugin name and the plugin version that can be used throughout the plugin.
	 * Load the dependencies, define the locale, and set the hooks for the admin area and
	 * the public-facing side of the site.
	 *
	 * @since    1.0.0
	 */
	public function __construct()
	{
		if (defined('METASYNC_VERSION')) {
			$this->version = METASYNC_VERSION;
		} else {
			$this->version = '1.0.0';
		}
		$this->plugin_name = 'metasync';

		$this->load_dependencies();
		// $this->set_locale(); // Language support removed - using default only
		$this->init_api_key_monitor();
		$this->define_admin_hooks();
		$this->define_public_hooks();
	}

	/**
	 * Load the required dependencies for this plugin.
	 *
	 * Include the following files that make up the plugin:
	 *
	 * - Metasync_Loader. Orchestrates the hooks of the plugin.
	 * - Metasync_i18n. Defines internationalization functionality.
	 * - Metasync_Admin. Defines all hooks for the admin area.
	 * - Metasync_Public. Defines all hooks for the public side of the site.
	 *
	 * Create an instance of the loader which will be used to register the hooks
	 * with WordPress.
	 *
	 * @since    1.0.0
	 * @access   private
	 */
	private function load_dependencies()
	{
		// WordPress core — cannot be autoloaded.
		require_once ABSPATH . 'wp-admin/includes/taxonomy.php';

		// Procedural init file — not a class, must stay explicit.
		if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
			require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
		} else {
			error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
		}

		// The SEO precedence resolver is referenced statically from the render
		// filters, the sync layer and four admin screens. Require it explicitly
		// for the same reason as the admin navigation below: a partial update can
		// leave newer PHP files beside an older committed autoload classmap, and
		// a missing class on wp_head or the posts list is a fatal, not a
		// degradation.
		if (!class_exists('Metasync_Seo_Precedence')) {
			require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-precedence.php';
		}

		// Admin navigation is referenced statically from frontend-reachable
		// includes (heartbeat/connect managers). Require it explicitly here so
		// the static call never fatals when wp_head fires before autoload.
		if (!class_exists('Metasync_Admin_Navigation')) {
			require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-admin-navigation.php';
		}

		// Hooked on init at priority 0, before any theme or plugin callback
		// runs, so it cannot wait for a lazy autoload. See define_public_hooks.
		if (!class_exists('Metasync_BookingPress_Compat')) {
			require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-bookingpress-compat.php';
		}

		$this->loader = new Metasync_Loader();
		// Install aggregate-option password protection in every runtime context.
		Metasync_Settings_Registration::instance();
		$this->db_heartbeat_errors = new Metasync_HeartBeat_Error_Monitor_Database();
		$this->db_redirection = new Metasync_Redirection_Database();
	}

	/**
	 * Define the locale for this plugin for internationalization.
	 *
	 * Uses the Metasync_i18n class in order to set the domain and to register the hook
	 * with WordPress.
	 *
	 * @since    1.0.0
	 * @access   private
	 */
	// Language support removed - using default only
	/*
	private function set_locale()
	{
		$plugin_i18n = new Metasync_i18n();

		$this->loader->add_action('plugins_loaded', $plugin_i18n, 'load_plugin_textdomain');
	}
	*/

	/**
	 * Initialize the API Key Monitor for comprehensive API key change detection
	 *
	 * @since    1.0.0
	 * @access   private
	 */
	private function init_api_key_monitor()
	{
		// Initialize the singleton instance of the API Key Monitor
		// This will automatically set up hooks to monitor all API key changes
		Metasync_API_Key_Monitor::get_instance();
		
		// Log successful initialization
		#commented out to stop appending this to error.php 
		# error_log('MetaSync: API Key Monitor initialized successfully');
	}

	/**
	 * Register all of the hooks related to the admin area functionality
	 * of the plugin.
	 *
	 * @since    1.0.0
	 * @access   private
	 */
	private function define_admin_hooks()
	{

		$plugin_admin = new Metasync_Admin($this->get_plugin_name(), $this->get_version(), $this->database, $this->db_redirection, $this->db_heartbeat_errors); // , $this->data_error_log_list

		// Initialize HTML Visual Editor
		$html_visual_editor = new Metasync_HTML_Visual_Editor($this->get_plugin_name(), $this->get_version());
		$html_visual_editor->init();

		// Initialize OTTO Debug class for developers
		if (class_exists('Metasync_Otto_Debug')) {
			$otto_debug = new Metasync_Otto_Debug($this->get_plugin_name(), $this->get_version());
		}

		// Initialize SEO Sidebar for Gutenberg Block Editor
		if (class_exists('Metasync_SEO_Sidebar')) {
			new Metasync_SEO_Sidebar($this->get_version());
		}

		// Initialize Internal Link Suggestions for Gutenberg Block Editor
		if (class_exists('Metasync_Link_Suggestions')) {
			new Metasync_Link_Suggestions();
		}

		$this->loader->add_action('admin_enqueue_scripts', $plugin_admin, 'enqueue_styles');
		$this->loader->add_action('admin_enqueue_scripts', $plugin_admin, 'enqueue_scripts');

		# Redirection import AJAX handler
		$redirection_handler = new Metasync_Redirection($this->db_redirection);
		$this->loader->add_action('wp_ajax_metasync_import_redirections', $redirection_handler, 'handle_import_ajax');
		$this->loader->add_action('wp_ajax_metasync_check_redirects_health', $redirection_handler, 'handle_health_check_ajax');

		// HeartBeat API Receive Respond and Settings.
		$this->loader->add_action('heartbeat_settings', $plugin_admin, 'metasync_heartbeat_settings');
		$this->loader->add_action('heartbeat_received', $plugin_admin, 'metasync_received_data', 10, 2);
		$this->loader->add_action('wp_ajax_metasync_send_customer_params', $plugin_admin, 'lgSendCustomerParams');
		
		// Search Atlas Connect endpoints - authenticates with Search Atlas platform to retrieve SA API key and Otto UUID
		$this->loader->add_action('wp_ajax_metasync_generate_connect_url', $plugin_admin, 'generate_searchatlas_connect_url');
		$this->loader->add_action('wp_ajax_metasync_check_connect_status', $plugin_admin, 'check_searchatlas_connect_status');
		$this->loader->add_action('wp_ajax_metasync_reset_authentication', $plugin_admin, 'reset_searchatlas_authentication');

		// Auto-update filter
		$this->loader->add_filter('auto_update_plugin', $plugin_admin, 'control_plugin_auto_updates', 10, 2);

		// Search Atlas Connect development/testing endpoints
		$this->loader->add_action('wp_ajax_metasync_test_enhanced_tokens', $plugin_admin, 'test_enhanced_searchatlas_tokens');
		$this->loader->add_action('wp_ajax_metasync_test_whitelabel_domain', $plugin_admin, 'test_whitelabel_domain');
		$this->loader->add_action('wp_ajax_metasync_test_ajax_endpoint', $plugin_admin, 'test_searchatlas_ajax_endpoint');
		$this->loader->add_action('wp_ajax_metasync_simple_ajax_test', $plugin_admin, 'simple_ajax_test');


		$post_meta_setting = new Metasync_Post_Meta_Settings();
		$this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2);
		$this->loader->add_action('admin_init', $post_meta_setting, 'show_top_admin_bar', 9);

		// Unified "SEO Suite" meta box — consolidates the separate Classic-editor
		// meta boxes above into one tabbed box (presentation-only; save handlers
		// unchanged). Classic editor only; the block editor keeps its SEO sidebar.
		// Self-registers its hooks in the constructor. Opt out via the
		// `metasync_enable_seo_suite` filter.
		require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-suite.php';
		new Metasync_Seo_Suite();

		// SEO Health CSV export: must run on admin_init (before output).
		// Cheap $_GET check avoids loading the class on every admin page.
		if (
			isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) &&
			$_GET['export'] === 'csv' &&
			strpos($_GET['page'], '-seo-health') !== false
		) {
			$this->loader->add_action('admin_init', Metasync_SEO_Health::get_instance(), 'handle_csv_export', 1);
		}
		$this->loader->add_action('wp', $post_meta_setting, 'show_top_admin_bar', 9);

		// SEO meta columns on the posts/pages list tables (WP-624).
		// Registered for both the `posts` and `pages` variants of each hook so the
		// columns reach every supported post type; the callbacks bail on unsupported
		// ones. Hidden by default — users opt in from Screen Options.
		$seo_columns = Metasync_SEO_Columns::get_instance();
		$this->loader->add_filter('manage_posts_columns', $seo_columns, 'add_columns', 10, 2);
		$this->loader->add_filter('manage_pages_columns', $seo_columns, 'add_columns', 10, 1);
		$this->loader->add_action('manage_posts_custom_column', $seo_columns, 'render_column', 10, 2);
		$this->loader->add_action('manage_pages_custom_column', $seo_columns, 'render_column', 10, 2);
		$this->loader->add_filter('default_hidden_columns', $seo_columns, 'default_hidden_columns', 10, 2);
		$this->loader->add_filter('hidden_columns', $seo_columns, 'hidden_columns', 10, 3);
		$this->loader->add_action('admin_head', $seo_columns, 'print_styles');
		$this->loader->add_action('admin_notices', $seo_columns, 'companion_plugin_notice');

		// Initialize XML Sitemap auto-update hooks if enabled
		// Note: Must not be gated by is_admin() because Gutenberg saves posts
		// via the REST API where is_admin() returns false, and REST_REQUEST
		// is not yet defined at plugin load time
		if (get_option('metasync_sitemap_auto_update', false)) {
			$sitemap_generator = new Metasync_Sitemap_Generator();
			$sitemap_generator->setup_auto_update_hooks();
		}
		// Initialize Schema Markup functionality
		$schema_markup = new Metasync_Schema_Markup($this->get_plugin_name(), $this->get_version());
		$this->loader->add_action('wp_ajax_metasync_get_schema_fields', $schema_markup, 'ajax_get_schema_fields');
		$this->loader->add_action('wp_ajax_metasync_preview_schema', $schema_markup, 'ajax_preview_schema');

		// Initialize Breadcrumbs functionality
		if (class_exists('Metasync_Breadcrumbs')) {
			new Metasync_Breadcrumbs($this->get_plugin_name(), $this->get_version());
		}
		if (class_exists('Metasync_Breadcrumbs_Schema')) {
			new Metasync_Breadcrumbs_Schema($this->get_plugin_name(), $this->get_version());
		}

		// Initialize Developer Panel (for endpoint switching)
		if (class_exists('Metasync_Dev_Panel')) {
			$dev_panel = new Metasync_Dev_Panel($this->get_plugin_name(), $this->get_version());
		}

		// Initialize Site Health integration
		if (class_exists('Metasync_Site_Health')) {
			$site_health = new Metasync_Site_Health();
			$site_health->register_tests();
		}

		// Initialize endpoint URL filtering for staging mode
		$this->init_endpoint_filtering();

	}

	/**
	 * Register all of the hooks related to the public-facing functionality
	 * of the plugin.
	 *
	 * @since    1.0.0
	 * @access   private
	 */
	private function define_public_hooks()
	{
		// Header and Footer code snippets
		$code_snippets = new Metasync_Code_Snippets();

		$this->loader->add_action('wp_head', $code_snippets, 'get_header_snippet');
		$this->loader->add_action('wp_footer', $code_snippets, 'get_footer_snippet');

		$plugin_public = new Metasync_Public($this->get_plugin_name(), $this->get_version());
		$rest_api = $plugin_public->get_rest_api();
		$seo_output = $plugin_public->get_seo_output();
		$get_plugin_basename = sprintf('%1$s/%1$s.php', $this->plugin_name);

		// Asset enqueue hooks (Metasync_Public)
		$this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
		$this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
		$this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_page_custom_css', 999);

		// Elementor editor CSS injection
		if (class_exists('\Elementor\Plugin')) {
			$this->loader->add_action('elementor/preview/enqueue_styles', $plugin_public, 'enqueue_elementor_editor_css', 999);
		}

		// Divi builder CSS injection
		if (function_exists('et_setup_theme')) {
			$this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_divi_builder_css', 999);
		}

		// Initialize centralized SEO conflict handler (singleton — suppresses
		// third-party SEO plugin descriptions when MetaSync provides its own).
		Metasync_SEO_Conflict_Handler::get_instance();

		// Term-level SEO plugin sync: propagate MetaSync term meta (category/tag
		// archives) into Yoast/Rank Math/AIOSEO term storage on every write.
		$this->loader->add_action('updated_term_meta', $this, 'on_term_meta_updated', 10, 4);
		$this->loader->add_action('added_term_meta', $this, 'on_term_meta_updated', 10, 4);

		// Post-level plugin sync: propagate MetaSync post meta into
		// Yoast/Rank Math/AIOSEO post storage on every write.
		$this->loader->add_action('updated_post_meta', $this, 'on_post_meta_updated', 10, 4);
		$this->loader->add_action('added_post_meta', $this, 'on_post_meta_updated', 10, 4);
		// Deletes matter too: clearing the last checkbox in a meta box removes
		// the meta row, which fires neither hook above.
		$this->loader->add_action('deleted_post_meta', $this, 'on_post_meta_deleted', 10, 3);

		// SEO Output hooks (Metasync_Seo_Output)
		$this->loader->add_action('wp_head', $seo_output, 'hook_metasync_metatags', 1, 1);
		// LocalBusiness / Organization / Person JSON-LD from the Local Business page.
		$this->loader->add_action('wp_head', $seo_output, 'output_local_business_schema', 2, 1);
		$this->loader->add_action('template_redirect', $seo_output, 'inject_archive_seo_controls');

		// Hreflang / language alternates output (wp_head @ priority 2).
		$plugin_hreflang = new Metasync_Hreflang_Output();
		$this->loader->add_action('wp_head', $plugin_hreflang, 'output_hreflang_tags', 2);

		// Edge Cache: detect Cloudways Varnish and persist for settings UI
		$this->loader->add_action('init', 'Metasync_Edge_Cache_Purge', 'detect_cloudways');

		// BookingPress starts a PHP session on every front-end request (init
		// at priority 1), which makes hosts skip page caching site-wide. Its
		// booking flows manage their own sessions inside their AJAX handlers,
		// so unhook the global start. Priority 0 keeps this ahead of theirs.
		// Registered with add_action directly (like Metasync_Oxygen_Compat)
		// because the loader's $component parameter is typed to objects.
		add_action('init', ['Metasync_BookingPress_Compat', 'neutralize_bookingpress_session'], 0);

		// Sitemap exclusions for disabled archive types
		$this->loader->add_filter('wp_sitemaps_taxonomies', $seo_output, 'filter_sitemap_taxonomies');
		$this->loader->add_filter('wp_sitemaps_users_entry', $seo_output, 'filter_sitemap_users', 10, 2);
		$this->loader->add_filter('wp_sitemaps_add_provider', $seo_output, 'filter_sitemap_providers', 10, 2);
		$this->loader->add_filter('wp_sitemaps_index_entry', $seo_output, 'filter_sitemap_index_entries', 10, 4);

		// AMP cleanup functionality - remove metasync_optimized attribute from head on AMP pages
		$this->loader->add_action('template_redirect', $seo_output, 'cleanup_amp_head_attribute', 1);
		$this->loader->add_action('wp_footer', $seo_output, 'end_amp_head_cleanup', 999);

		// Redirection functionality
		$redirection = new Metasync_Redirection($this->db_redirection);
		$this->loader->add_action('template_redirect', $redirection, 'handle_template_redirect', 5);

		# Prevent WordPress from redirecting to draft posts via redirect_canonical
		$this->loader->add_filter('redirect_canonical', $redirection, 'prevent_draft_post_redirects', 10, 2);

		# Prevent WordPress old slug redirects to unpublished posts only
		$this->loader->add_filter('old_slug_redirect_post_id', $redirection, 'prevent_old_slug_redirect_to_drafts', 10, 1);

		// Auto-redirect on slug change - creates 301 redirect when post/page slug is changed
		$auto_redirect = new Metasync_Auto_Redirect($this->db_redirection);
		$auto_redirect->init();

		# Custom HTML Pages functionality
		# No additional loader hooks needed - class registers its own hooks
		$custom_pages = new Metasync_Custom_Pages();

		// 404 Error monitoring
		$this->loader->add_action('template_redirect', $this, 'handle_404_monitoring', 10);
		$this->loader->add_action('plugin_action_links_' . $get_plugin_basename, $plugin_public, 'metasync_plugin_links');

		// REST API hooks (Metasync_Rest_Api)
		$this->loader->add_action('rest_api_init', $rest_api, 'metasync_register_rest_routes');
		// Coexist with third-party JWT auth plugins: clear their prior auth error
		// for metasync/v1 requests when our own API key validates. The Tmeister
		// "JWT Authentication for WP-API" plugin surfaces its jwt_auth_invalid_token
		// 403 via rest_pre_dispatch (priority 10), so we hook the same filter at a
		// later priority (11) to clear it for our namespace only.
		$this->loader->add_filter('rest_pre_dispatch', $rest_api, 'allow_metasync_rest_auth', 11, 3);
		// Coexist with site-wide "authenticated users only" REST restrictions.
		// Those hook rest_authentication_errors, which WP applies before dispatch,
		// so rest_pre_dispatch and permission_callback never run. Hook it late
		// (99) to clear the error for metasync/v1 requests that present a valid
		// plugin API key; every other route keeps the site's restriction.
		$this->loader->add_filter('rest_authentication_errors', $rest_api, 'allow_metasync_rest_authentication', 99, 1);
		$this->loader->add_action('init', $plugin_public, 'metasync_plugin_init', 5);
		$this->loader->add_action('wp_ajax_metasync_lglogin', $rest_api, 'linkgraph_login');

		// Robots meta filter (Metasync_Seo_Output)
		$this->loader->add_filter('wp_robots', $seo_output, 'metasync_wp_robots_meta');


		
		$metasyncTemplateClass = new Metasync_Template();
		$this->loader->add_filter('theme_page_templates', $metasyncTemplateClass, 'metasync_template_landing_page', 10, 3);
		$this->loader->add_filter('template_include', $metasyncTemplateClass, 'metasync_template_landing_page_load', 99 );
		$templateCrawler = new MetaSyncHiddenPostManager(); # initialize the crawler class

		$this->loader->add_action('wp_trash_post', $templateCrawler , 'prevent_post_deletion'); # Prevent post deletion when moved to trash
        $this->loader->add_action('before_delete_post', $templateCrawler , 'prevent_post_deletion'); # Prevent permanent deletion
		# $this->loader->add_filter('metasync_hidden_post_manager', $templateCrawler , 'init'); # run the crawler
		# Hidden post manager now runs via cron instead of filter (to avoid interfering with post create/update)
		$this->loader->add_action('metasync_hidden_post_check', $templateCrawler , 'init'); # run the crawler via cron

		// Open Graph and Social Media Tags
		$opengraph = new Metasync_OpenGraph($this->get_plugin_name(), $this->get_version());
		$opengraph->init();

		# Save current theme info to database (safe context - admin/init hooks)
		$this->loader->add_action('after_switch_theme', $this, 'save_current_theme_info');
		$this->loader->add_action('admin_init', $this, 'ensure_theme_info_saved');

		// OTTO Frontend Toolbar
		$otto_toolbar = new Metasync_Otto_Frontend_Toolbar($this->get_plugin_name(), $this->get_version());
		$this->loader->add_action('wp_enqueue_scripts', $otto_toolbar, 'enqueue_styles');
		$this->loader->add_action('wp_enqueue_scripts', $otto_toolbar, 'enqueue_scripts');
		$this->loader->add_action('admin_bar_menu', $otto_toolbar, 'add_admin_bar_menu', 100);
		$this->loader->add_action('wp_footer', $otto_toolbar, 'render_debug_bar', 999);

		// Initialize Sitemap Generator on frontend (for virtual sitemap serving)
		$sitemap_generator = new Metasync_Sitemap_Generator();

		// Initialize LLMs.txt Generator (for virtual /llms.txt and /llms-full.txt serving)
		require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-html-to-markdown.php';
		require_once plugin_dir_path(dirname(__FILE__)) . 'llms-txt/class-metasync-llms-txt-generator.php';
		$llms_txt_generator = new Metasync_Llms_Txt_Generator();

		// Serve the IndexNow key file virtually at /{key}.txt so it works
		// on read-only web roots and nginx hosts that 403 direct static .txt access.
		require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
		add_action('template_redirect', array('Metasync_Bing_Instant_Index', 'serve_virtual_key_file'), 0);

		// Scheduled IndexNow submissions, deferred from save_post so a slow
		// IndexNow round-trip cannot stall the editor or REST write. Registered
		// statically because the class is loaded but never instantiated on
		// cron loads. The hook name matches the uninstall cron sweep exactly.
		add_action('metasync_bing_indexnow_submit_event', array('Metasync_Bing_Instant_Index', 'run_scheduled_submit'), 10, 1);

		// Construct the robots.txt manager on the public load path so its
		// `robots_txt` filter is registered before a plain GET /robots.txt is
		// served. The filter is attached in the singleton's constructor, and
		// previously only admin screens built the instance — so the virtual
		// robots.txt rules and sitemap lines never rendered on the frontend.
		require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
		Metasync_Robots_Txt::get_instance();

		// One-time upgrade: regenerate sitemap to remove any Beaver Builder template entries
		if ( ! get_option( 'metasync_sitemap_bb_exclusion_applied' ) ) {
			$this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade');
		}
	}

	/**
	 * One-time upgrade routine: regenerate the XML sitemap so that Beaver Builder
	 * template post types (fl-builder-template, fl-theme-layout) that were already
	 * present in previously-generated sitemaps are purged.
	 *
	 * Runs once on 'init' and sets a flag so it never runs again.
	 *
	 * @since 1.0.0
	 */
	public function maybe_regenerate_sitemap_after_upgrade() {
		$done_key = 'metasync_sitemap_bb_exclusion_applied';
		if ( get_option( $done_key ) ) {
			return;
		}

		// Only regenerate if the custom sitemap feature is actually in use.
		if ( get_option( 'metasync_sitemap_auto_update', false ) || file_exists( ABSPATH . 'sitemap_index.xml' ) ) {
			if ( ! class_exists( 'Metasync_Sitemap_Generator' ) ) {
				require_once plugin_dir_path( dirname( __FILE__ ) ) . 'sitemap/class-metasync-sitemap-generator.php';
			}
			$sitemap = new Metasync_Sitemap_Generator();
			$sitemap->generate_sitemap();
			update_option( $done_key, true );
		}
		// If sitemap is not in use, don't set the flag — retry on next load
		// so that enabling sitemaps later will still clean up BB templates.
	}

	/**
	 * Initialize endpoint URL filtering for staging mode
	 * Intercepts HTTP requests and replaces production URLs with staging URLs
	 */
	private function init_endpoint_filtering() {
		// Only add filter if Endpoint Manager is available and staging mode is active
		if (!class_exists('Metasync_Endpoint_Manager') || !Metasync_Endpoint_Manager::is_staging_mode()) {
			return;
		}

		// Add filter to intercept HTTP requests before they're sent
		add_filter('pre_http_request', array($this, 'filter_http_request_urls'), 10, 3);
	}

	/**
	 * Filter HTTP request URLs to replace production endpoints with staging
	 *
	 * @param false|array|WP_Error $preempt Whether to preempt an HTTP request's return value.
	 * @param array $args HTTP request arguments.
	 * @param string $url The request URL.
	 * @return false|array|WP_Error
	 */
	public function filter_http_request_urls($preempt, $args, $url) {
		// Only process if we're not preempting the request
		if ($preempt !== false) {
			return $preempt;
		}

		// Only process if staging mode is active
		if (!class_exists('Metasync_Endpoint_Manager') || !Metasync_Endpoint_Manager::is_staging_mode()) {
			return $preempt;
		}

		// Define URL replacements (production => staging)
		$url_replacements = array(
			'https://dashboard.searchatlas.com' => 'https://dashboard.staging.searchatlas.com',
			'https://api.searchatlas.com' => 'https://api.staging.searchatlas.com',
			'https://ca.searchatlas.com' => 'https://ca.staging.searchatlas.com',
			'https://sa.searchatlas.com' => 'https://sa.staging.searchatlas.com',
		);

		// Check if URL needs to be replaced
		$original_url = $url;
		foreach ($url_replacements as $production => $staging) {
			if (strpos($url, $production) === 0) {
				$url = str_replace($production, $staging, $url);
				error_log("MetaSync Endpoint Filter: Replaced {$production} with {$staging} in URL: {$original_url}");
				break;
			}
		}

		// If URL was changed, modify the args and make the request ourselves
		if ($url !== $original_url) {
			// Make the request with the modified URL
			return wp_remote_request($url, $args);
		}

		return $preempt;
	}

	/**
	 * Term meta update hook: mirror MetaSync term meta (`_metasync_*`)
	 * into the active third-party SEO plugins' term storage.
	 *
	 * Registered on both `updated_term_meta` and `added_term_meta` so new
	 * fields are synced the first time they are written as well as on
	 * subsequent updates.
	 *
	 * @param int    $meta_id    Meta row ID (unused).
	 * @param int    $object_id  Term ID.
	 * @param string $meta_key   Meta key being written.
	 * @param mixed  $meta_value Meta value being written.
	 */
	public function on_term_meta_updated($meta_id, $object_id, $meta_key, $meta_value) {
		if (strncmp($meta_key, '_metasync_', 10) !== 0) {
			return;
		}

		if (!class_exists('Metasync_Term_Plugin_Sync')) {
			return;
		}

		$term = get_term((int) $object_id);
		if (!$term || is_wp_error($term)) {
			return;
		}

		$canonical_map = [
			'_metasync_metatitle'          => 'title',
			'_metasync_metadesc'           => 'desc',
			'_metasync_robots_index'       => 'noindex',
			'_metasync_canonical_url'      => 'canonical',
			'_metasync_og_title'           => 'og_title',
			'_metasync_og_description'     => 'og_desc',
			'_metasync_og_image'           => 'og_image',
			'_metasync_twitter_title'      => 'twitter_title',
			'_metasync_twitter_description' => 'twitter_desc',
		];

		if (!isset($canonical_map[$meta_key])) {
			return;
		}

		$canonical_key = $canonical_map[$meta_key];

		Metasync_Term_Plugin_Sync::get_instance()->sync_term(
			(int) $object_id,
			(string) $term->taxonomy,
			[$canonical_key => $meta_value]
		);
	}

	/**
	 * Post meta update hook: mirror MetaSync post meta (`_metasync_*`)
	 * into the active third-party SEO plugins' post storage.
	 *
	 * Registered on both `updated_post_meta` and `added_post_meta` so new
	 * fields are synced the first time they are written as well as on
	 * subsequent updates.
	 *
	 * @param int    $meta_id    Meta row ID (unused).
	 * @param int    $post_id    Post ID.
	 * @param string $meta_key   Meta key being written.
	 * @param mixed  $meta_value Meta value being written.
	 */
	public function on_post_meta_updated($meta_id, $post_id, $meta_key, $meta_value) {
		if (!self::is_metasync_meta_key($meta_key)) {
			return;
		}

		if (!self::sync_layer_handles('on_meta_updated')) {
			return;
		}

		Metasync_Plugin_Sync::get_instance()->on_meta_updated($meta_id, $post_id, $meta_key, $meta_value);
	}

	/**
	 * Bridge deleted_post_meta to the sync layer.
	 *
	 * Clearing the last checkbox in a meta box deletes its meta row rather than
	 * updating it, so the update hooks above never fire and mirrored values can
	 * go stale. Only the legacy robots keys are acted on; see
	 * Metasync_Plugin_Sync::on_meta_deleted().
	 *
	 * @param array  $meta_ids Meta row IDs (unused).
	 * @param int    $post_id  Post ID.
	 * @param string $meta_key Meta key being deleted.
	 */
	public function on_post_meta_deleted($meta_ids, $post_id, $meta_key) {
		if (!self::is_metasync_meta_key($meta_key)) {
			return;
		}

		if (!self::sync_layer_handles('on_meta_deleted')) {
			return;
		}

		Metasync_Plugin_Sync::get_instance()->on_meta_deleted($meta_ids, $post_id, $meta_key);
	}

	/**
	 * Is this meta key one the post sync layer could possibly care about?
	 *
	 * Cheap string gate, no autoload, no singleton. Every watched post key is
	 * either `_metasync_*` (sidebar, OTTO and the mirrored robots JSON) or
	 * `metasync_*` (the legacy meta box keys), so this keeps third-party meta
	 * writes out of the sync layer entirely — `deleted_post_meta` and
	 * `updated_post_meta` fire for every key on the site, including on front-end
	 * requests, and crossing into the sync layer for keys it will only discard
	 * costs an autoload plus a singleton on the hot path.
	 *
	 * Deliberately broader than the sync layer's own key maps: those stay the
	 * single source of exact truth, so a new MetaSync key needs no change here.
	 *
	 * @param string $meta_key Meta key being written or deleted.
	 * @return bool
	 */
	private static function is_metasync_meta_key($meta_key) {
		return strncmp($meta_key, '_metasync_', 10) === 0
			|| strncmp($meta_key, 'metasync_', 9) === 0;
	}

	/**
	 * Can the post sync layer actually handle this hook right now?
	 *
	 * A partially updated install can leave a newer class-metasync.php beside an
	 * older class-metasync-plugin-sync.php — stale opcache bytecode for one file
	 * is enough. Calling a method the loaded class does not define is a fatal,
	 * and because these bridges run on `wp_head` via third-party meta writes it
	 * takes the front end down rather than degrading. Check before dispatching.
	 *
	 * Checked on the class, not an instance, so a mismatch skips the singleton.
	 *
	 * @param string $method Sync-layer method about to be called.
	 * @return bool
	 */
	private static function sync_layer_handles($method) {
		return class_exists('Metasync_Plugin_Sync')
			&& method_exists('Metasync_Plugin_Sync', 'get_instance')
			&& method_exists('Metasync_Plugin_Sync', $method);
	}

	/**
	 * Save current theme information to MetaSync options
	 * This runs in WordPress admin context, not during REST API requests
	 * Safe to use wp_get_theme() here
	 */
	public function save_current_theme_info() {
		$theme = wp_get_theme();
		$metasync_data = self::get_option();
		
		if (!isset($metasync_data['general'])) {
			$metasync_data['general'] = array();
		}
		
		$metasync_data['general']['current_theme_name'] = $theme->get('Name');
		$metasync_data['general']['current_theme_template'] = $theme->get_template();
		$metasync_data['general']['theme_info_updated'] = time();
		
		self::set_option($metasync_data);
	}
	
	/**
	 * Ensure theme info is saved on admin_init if not already saved
	 * This ensures theme info is available even if theme wasn't switched
	 */
	public function ensure_theme_info_saved() {
		$metasync_data = self::get_option('general');
		
		# Only run once per day to avoid overhead
		if (empty($metasync_data['theme_info_updated']) || 
		    (time() - $metasync_data['theme_info_updated']) > 86400) {
			$this->save_current_theme_info();
		}
	}

	public static function get_option($key = null, $default = null)
	{
		$options = get_option(Metasync::option_name);
		if (empty($options)) $options = [];
		if ($key === null) return $options;
		return $options[$key] ?? ($default !== null ? $default : null);
	}

	public static function set_option($data)
	{
		#return update_option(Metasync::option_name, $data);
		$result = update_option(Metasync::option_name, $data);
		
		// NEW: Structured error logging for database errors (only log if it's a real DB error)
		global $wpdb;
		if ($result === false && class_exists('Metasync_Error_Logger') && !empty($wpdb->last_error)) {
			// Check if it's actually a database error (not just same value)
			$saved_data = get_option(Metasync::option_name);
			if ($saved_data !== $data) {
				// Value is different but save failed - this is a real database error
				Metasync_Error_Logger::log(
					Metasync_Error_Logger::CATEGORY_DATABASE_ERROR,
					Metasync_Error_Logger::SEVERITY_ERROR,
					'Failed to save plugin main options to database',
					[
						'option_name' => Metasync::option_name,
						'wpdb_error' => $wpdb->last_error,
						'wpdb_last_query' => $wpdb->last_query,
						'operation' => 'set_option',
						'has_api_key' => !empty($data['general']['searchatlas_api_key'] ?? null),
						'has_auth_token' => !empty($data['general']['apikey'] ?? null)
					]
				);
			}
		}

		return $result;
	}

	/**
	 * Derive the 32-byte AES key from existing WordPress salts.
	 *
	 * No new secret is stored anywhere — the key material is the concatenation
	 * of three WordPress salts, hashed to a fixed 32 bytes. If the salts change
	 * (e.g. wp-config regenerated) the derived key changes and previously
	 * encrypted values can no longer be decrypted, which is handled gracefully
	 * by the callers (re-authenticate state) rather than fataling.
	 *
	 * @return string 32 raw bytes.
	 */
	private static function api_key_crypto_key()
	{
		$material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
		return hash('sha256', $material, true);
	}

	/**
	 * Determine whether a stored value is in the encrypted-at-rest format.
	 *
	 * @param mixed $value
	 * @return bool
	 */
	public static function is_encrypted_api_key($value)
	{
		return is_string($value) && strncmp($value, self::API_KEY_ENC_PREFIX, strlen(self::API_KEY_ENC_PREFIX)) === 0;
	}

	/**
	 * Encrypt a plaintext Search Atlas API key for storage at rest.
	 *
	 * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the
	 * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded
	 * behind an `enc_v1:` prefix. An empty string is stored as-is (no key set).
	 *
	 * When OpenSSL is unavailable or encryption fails the plaintext is stored
	 * unchanged (availability over hard-fail) and the degradation is logged so
	 * it cannot go unnoticed.
	 *
	 * @param string $plaintext
	 * @return string Encrypted blob, or '' when $plaintext is empty.
	 */
	public static function encrypt_api_key($plaintext)
	{
		$plaintext = (string) $plaintext;
		if ($plaintext === '') {
			return '';
		}

		// Already encrypted — do not double-encrypt.
		if (self::is_encrypted_api_key($plaintext)) {
			return $plaintext;
		}

		if (!function_exists('openssl_encrypt')) {
			// OpenSSL unavailable — store plaintext rather than lose the key.
			error_log('MetaSync: OpenSSL is unavailable — the Search Atlas API key was stored WITHOUT encryption at rest.');
			return $plaintext;
		}

		$key = self::api_key_crypto_key();
		$iv  = random_bytes(12);
		$tag = '';
		$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);

		if ($ciphertext === false) {
			// Encryption failed — fall back to plaintext storage, but say so.
			error_log('MetaSync: API key encryption failed — the Search Atlas API key was stored WITHOUT encryption at rest.');
			return $plaintext;
		}

		return self::API_KEY_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
	}

	/**
	 * Decrypt a stored Search Atlas API key value.
	 *
	 * Accepts either the encrypted `enc_v1:` format or a legacy plaintext value
	 * (returned unchanged, supporting installs that pre-date encryption). On any
	 * decryption failure (salt change / corruption) returns false so callers can
	 * surface a re-authenticate state instead of using a bad key.
	 *
	 * @param mixed $value
	 * @return string|false Plaintext, or false when an encrypted value cannot be decrypted.
	 */
	public static function decrypt_api_key($value)
	{
		if (!is_string($value) || $value === '') {
			return '';
		}

		if (!self::is_encrypted_api_key($value)) {
			// Legacy plaintext key.
			return $value;
		}

		if (!function_exists('openssl_decrypt')) {
			return false;
		}

		$raw = base64_decode(substr($value, strlen(self::API_KEY_ENC_PREFIX)), true);
		if ($raw === false || strlen($raw) < 12 + 16 + 1) {
			return false;
		}

		$iv         = substr($raw, 0, 12);
		$tag        = substr($raw, 12, 16);
		$ciphertext = substr($raw, 28);

		$key = self::api_key_crypto_key();
		$plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);

		if ($plaintext === false) {
			return false;
		}

		return $plaintext;
	}

	/**
	 * Get the decrypted Search Atlas API key, memoized for the request.
	 *
	 * Decrypts at most once per request and never persists the decrypted value
	 * anywhere. When a legacy plaintext key is found it is migrated to the
	 * encrypted format in place (one-time migration on load). Returns:
	 *   - the plaintext key (string, possibly '')
	 *   - false when an encrypted value exists but cannot be decrypted
	 *     (salts changed / corrupt) — callers should treat this as a
	 *     "please re-authenticate" state.
	 *
	 * @return string|false
	 */
	public static function get_searchatlas_api_key()
	{
		if (self::$memo_api_key !== null) {
			return self::$memo_api_key;
		}

		$general = self::get_option('general');
		$stored  = is_array($general) ? ($general['searchatlas_api_key'] ?? '') : '';

		// One-time migration: a non-empty legacy plaintext value is encrypted
		// in place the first time it is read after this feature ships.
		if ($stored !== '' && is_string($stored) && !self::is_encrypted_api_key($stored)) {
			$encrypted = self::encrypt_api_key($stored);
			if (self::is_encrypted_api_key($encrypted)) {
				$options = self::get_option();
				if (!is_array($options)) {
					$options = [];
				}
				$options['general']['searchatlas_api_key'] = $encrypted;
				self::set_option($options);
			}
		}

		self::$memo_api_key = self::decrypt_api_key($stored);
		return self::$memo_api_key;
	}

	/**
	 * Clear the request-scoped decrypted-key memo.
	 *
	 * Call after any write that changes the stored searchatlas_api_key so a
	 * subsequent read in the same request reflects the new value.
	 */
	public static function invalidate_api_key_cache()
	{
		self::$memo_api_key = null;
	}

	/**
	 * Read the heartbeat throttle state from its dedicated option.
	 *
	 * Backfills from the legacy location (`metasync_options['general']`) the
	 * first time the dedicated option is empty, so existing installs keep
	 * their throttle history across the migration.
	 */
	public static function get_heartbeat_throttle(): array
	{
		$value = get_option(self::heartbeat_throttle_option, []);
		if (is_array($value) && !empty($value)) {
			return $value;
		}

		$general = self::get_option('general');
		if (is_array($general) && (array_key_exists('last_heart_beat', $general) || array_key_exists('last_heartbeat_at', $general))) {
			$throttle = [
				'last_heart_beat' => $general['last_heart_beat'] ?? 0,
				'last_heartbeat_at' => $general['last_heartbeat_at'] ?? null,
			];
			update_option(self::heartbeat_throttle_option, $throttle);
			return $throttle;
		}

		return [];
	}

	/**
	 * Merge fields into the dedicated heartbeat throttle option.
	 *
	 * Writes via update_option directly so the main metasync_options blob is
	 * never read or rewritten — avoiding the read-modify-write race with
	 * concurrent settings saves.
	 */
	public static function set_heartbeat_throttle(array $fields): void
	{
		$existing = get_option(self::heartbeat_throttle_option, []);
		if (!is_array($existing)) {
			$existing = [];
		}
		$merged = array_merge($existing, $fields);
		update_option(self::heartbeat_throttle_option, $merged);
	}

	/**
	 * Storage prefix marking a secret value as encrypted at rest.
	 */
	private const SECRET_ENC_PREFIX = 'enc_v1:';

	/**
	 * Derive the 32-byte AES key from existing WordPress salts.
	 *
	 * No new secret is stored anywhere — the key material is the concatenation
	 * of three WordPress salts, hashed to a fixed 32 bytes. If the salts change
	 * (e.g. wp-config regenerated) the derived key changes and previously
	 * encrypted values can no longer be decrypted, which callers handle
	 * gracefully rather than fataling.
	 *
	 * @return string 32 raw bytes.
	 */
	private static function secret_crypto_key()
	{
		$material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
		return hash('sha256', $material, true);
	}

	/**
	 * Determine whether a stored value is in the encrypted-at-rest format.
	 *
	 * @param mixed $value
	 * @return bool
	 */
	public static function is_encrypted_secret($value)
	{
		return is_string($value) && strncmp($value, self::SECRET_ENC_PREFIX, strlen(self::SECRET_ENC_PREFIX)) === 0;
	}

	/**
	 * Encrypt a plaintext secret (e.g. the whitelabel settings password) for
	 * storage at rest.
	 *
	 * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the
	 * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded
	 * behind an `enc_v1:` prefix. An empty string is stored as-is (no secret).
	 *
	 * When OpenSSL is unavailable or encryption fails the plaintext is stored
	 * unchanged (availability over hard-fail) and the degradation is logged so
	 * it cannot go unnoticed.
	 *
	 * @param string $plaintext
	 * @return string Encrypted blob, or '' when $plaintext is empty.
	 */
	public static function encrypt_secret($plaintext)
	{
		$plaintext = (string) $plaintext;
		if ($plaintext === '') {
			return '';
		}

		// Already encrypted — do not double-encrypt.
		if (self::is_encrypted_secret($plaintext)) {
			return $plaintext;
		}

		if (!function_exists('openssl_encrypt')) {
			// OpenSSL unavailable — store plaintext rather than lose the secret.
			error_log('MetaSync: OpenSSL is unavailable — a secret was stored WITHOUT encryption at rest.');
			return $plaintext;
		}

		$key = self::secret_crypto_key();
		$iv  = random_bytes(12);
		$tag = '';
		$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);

		if ($ciphertext === false) {
			// Encryption failed — fall back to plaintext storage, but say so.
			error_log('MetaSync: Secret encryption failed — a secret was stored WITHOUT encryption at rest.');
			return $plaintext;
		}

		return self::SECRET_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
	}

	/**
	 * Decrypt a stored secret value.
	 *
	 * Accepts either the encrypted `enc_v1:` format or a legacy plaintext value
	 * (returned unchanged, supporting installs that pre-date encryption). On any
	 * decryption failure (salt change / corruption) returns false so callers can
	 * degrade gracefully instead of using a bad value.
	 *
	 * @param mixed $value
	 * @return string|false Plaintext, or false when an encrypted value cannot be decrypted.
	 */
	public static function decrypt_secret($value)
	{
		if (!is_string($value) || $value === '') {
			return '';
		}

		if (!self::is_encrypted_secret($value)) {
			// Legacy plaintext value.
			return $value;
		}

		if (!function_exists('openssl_decrypt')) {
			return false;
		}

		$raw = base64_decode(substr($value, strlen(self::SECRET_ENC_PREFIX)), true);
		if ($raw === false || strlen($raw) < 12 + 16 + 1) {
			return false;
		}

		$iv         = substr($raw, 0, 12);
		$tag        = substr($raw, 12, 16);
		$ciphertext = substr($raw, 28);

		$key = self::secret_crypto_key();
		$plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);

		if ($plaintext === false) {
			return false;
		}

		return $plaintext;
	}

	/**
	 * Get the decrypted whitelabel settings password.
	 *
	 * Reads the stored (encrypted) value and returns the plaintext for
	 * verification or authorized display. A legacy plaintext value found in
	 * storage is migrated to the encrypted format in place (one-time migration
	 * on read). Returns '' when no password is set or when an encrypted value
	 * can no longer be decrypted (salts changed / corrupt) — in that case the
	 * stored value still counts as "password set" for protection checks, but
	 * the user password cannot authenticate until it is reset.
	 *
	 * @return string
	 */
	public static function get_whitelabel_password()
	{
		$whitelabel = self::get_whitelabel_settings();
		$stored = $whitelabel['settings_password'] ?? '';

		if (!is_string($stored) || $stored === '') {
			return '';
		}

		// One-time migration: encrypt a legacy plaintext value in place.
		// This is a whole-blob read-modify-write of metasync_options during a
		// read request; a concurrent settings save could theoretically clobber
		// it, but it fires at most once per legacy install so the window is
		// accepted rather than adding a dedicated option.
		if (!self::is_encrypted_secret($stored)) {
			$encrypted = self::encrypt_secret($stored);
			if (self::is_encrypted_secret($encrypted)) {
				$options = self::get_option();
				if (!is_array($options)) {
					$options = [];
				}
				$options['whitelabel']['settings_password'] = $encrypted;
				self::set_option($options);
			}
			return $stored;
		}

		$plaintext = self::decrypt_secret($stored);
		return $plaintext === false ? '' : $plaintext;
	}

	/**
	 * Get whitelabel settings
	 * Helper method to retrieve whitelabel configuration
	 */
	public static function get_whitelabel_settings()
	{
		$whitelabel = self::get_option('whitelabel');
		return is_array($whitelabel) ? $whitelabel : array(
			'is_whitelabel' => false,
			'domain' => '',
			'logo' => '',
			'logo_light' => '',
			'logo_dark' => '',
			'company_name' => '',
			'color_palette' => array(),
			'updated_at' => 0
		);
	}

	/**
	 * Check if whitelabel mode is enabled
	 */
	public static function is_whitelabel_enabled()
	{
		$whitelabel = self::get_whitelabel_settings();
		return isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true;
	}

	/**
	 * Get effective dashboard domain for the plugin
	 * Returns whitelabel domain if set (regardless of is_whitelabel flag), otherwise respects staging/production mode
	 */
	public static function get_dashboard_domain()
	{
		$whitelabel = self::get_whitelabel_settings();

		// Priority 1: Use whitelabel domain if it's not empty (regardless of is_whitelabel flag)
		if (!empty($whitelabel['domain'])) {
			return $whitelabel['domain'];
		}

		// Priority 2: Use endpoint manager to respect staging/production mode
		if (class_exists('Metasync_Endpoint_Manager')) {
			return Metasync_Endpoint_Manager::get_endpoint('DASHBOARD_DOMAIN');
		}

		// Priority 3: Fallback to production default domain
		return self::DASHBOARD_DOMAIN;
	}

	/**
	 * Get whitelabel logo URL
	 * Returns the whitelabel logo URL if logo is set
	 */
	public static function get_whitelabel_logo()
	{
		$whitelabel = self::get_whitelabel_settings();
		
		// Return logo if it's set and is a valid URL
		// Users should be able to set a custom logo without requiring a custom domain
		if (!empty($whitelabel['logo'])) {
			return $whitelabel['logo'];
		}
		
		return null;
	}

	/**
	 * Get whitelabel logo URL for light theme
	 * Falls back to legacy 'logo' field if logo_light is not set
	 */
	public static function get_whitelabel_logo_light()
	{
		$whitelabel = self::get_whitelabel_settings();

		if (!empty($whitelabel['logo_light'])) {
			return $whitelabel['logo_light'];
		}

		if (!empty($whitelabel['logo'])) {
			return $whitelabel['logo'];
		}

		return null;
	}

	/**
	 * Get whitelabel logo URL for dark theme
	 * Falls back to legacy 'logo' field if logo_dark is not set
	 */
	public static function get_whitelabel_logo_dark()
	{
		$whitelabel = self::get_whitelabel_settings();

		if (!empty($whitelabel['logo_dark'])) {
			return $whitelabel['logo_dark'];
		}

		if (!empty($whitelabel['logo'])) {
			return $whitelabel['logo'];
		}

		return null;
	}

	/**
	 * Get whitelabel company name
	 * Returns the whitelabel company name if whitelabel is active and company name is set
	 */
	public static function get_whitelabel_company_name()
	{
		$whitelabel = self::get_whitelabel_settings();
		
		// Return company name only if whitelabel is active and company name is set
		if (isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true && !empty($whitelabel['company_name'])) {
			return $whitelabel['company_name'];
		}
		
		return null;
	}

	/**
	 * Get whitelabel OTTO name
	 * Returns the custom OTTO name if set, otherwise returns 'OTTO'
	 */
	public static function get_whitelabel_otto_name()
	{
		$general_settings = self::get_option('general');
		
		// Return custom OTTO name if set, otherwise fallback to 'OTTO'
		if (!empty($general_settings['whitelabel_otto_name'])) {
			return $general_settings['whitelabel_otto_name'];
		}
		
		return 'OTTO';
	}

	/**
	 * Check if the current user has access to the plugin based on role settings
	 * 
	 * @return bool True if user has access, false otherwise
	 */
	public static function current_user_has_plugin_access()
	{
		$user = wp_get_current_user();
		if (!$user || !$user->exists()) {
			return false;
		}

		// Administrators always have access
		if (in_array('administrator', (array) $user->roles)) {
			return true;
		}

		// Get the plugin access roles setting
		$general_options = self::get_option('general');
		
		// If setting not configured, default to admin-only access
		if (!isset($general_options['plugin_access_roles'])) {
			return false;
		}
		
		$allowed_roles = $general_options['plugin_access_roles'];
		
		// If it's a string (single role), convert to array
		if (!is_array($allowed_roles)) {
			$allowed_roles = array($allowed_roles);
		}
		
		// If "all" is selected, allow access
		if (in_array('all', $allowed_roles)) {
			return true;
		}
		
		// If array is empty, deny access (only admins allowed)
		if (empty($allowed_roles)) {
			return false;
		}
		
		// Check if user has any of the allowed roles
		$user_roles = (array) $user->roles;
		return !empty(array_intersect($user_roles, $allowed_roles));
	}

	/**
	 * Get active JWT token for Search Atlas API authentication
	 * Convenience method accessible from anywhere in the plugin
	 * 
	 * @param bool $force_refresh Force generation of new token even if cached one exists
	 * @return string|false JWT token on success, false on failure
	 */
	public static function get_jwt_token($force_refresh = false)
	{
		// Delegate to admin class method
		return Metasync_Admin::get_active_jwt_token($force_refresh);
	}

	/**
	 * Get effective plugin name
	 * Returns plugin name respecting white label settings
	 * Priority: 1) white_label_plugin_name 2) company branding + base_name 3) base_name
	 */
	public static function get_effective_plugin_name($base_name = 'Search Atlas')
	{
		$general_settings = self::get_option('general');
		
		// Priority 1: Use white_label_plugin_name if set and not empty
		if (!empty($general_settings['white_label_plugin_name'])) {
			return $general_settings['white_label_plugin_name'];
		}
		
		$whitelabel = self::get_whitelabel_settings();
		
		// Priority 2: If whitelabel is enabled and company name is provided, enhance the plugin name
		if (isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true && !empty($whitelabel['company_name'])) {
			return $whitelabel['company_name'] . ' ' . $base_name;
		}
		
		// Priority 3: Return base_name as fallback
		return $base_name;
	}

	/**
	 * Render a standalone info-icon tooltip (the same visual/JS pattern used by
	 * get_field_tooltips() + render_accordion_sections() in Metasync_Settings_Fields).
	 * Use this on any admin page whose fields are NOT rendered through that
	 * accordion field-loop (custom render_callback pages, standalone view files) —
	 * the trigger/hover/positioning JS in admin/js/metasync-admin.js binds to
	 * `.metasync-tooltip-trigger` globally, so no extra wiring is needed as long
	 * as this markup is present on a page where metasync-admin.js is enqueued
	 * (i.e. any admin page under this plugin's menu).
	 *
	 * @param string $tooltip_id Unique id for this tooltip (unique per page).
	 * @param string $text       Plain-English help text (escaped internally).
	 */
	public static function render_tooltip_icon($tooltip_id, $text)
	{
		echo self::get_tooltip_icon_html($tooltip_id, $text);
	}

	/**
	 * Same tooltip markup as render_tooltip_icon(), but RETURNS the HTML string
	 * instead of echoing it. Use this when the tooltip needs to be concatenated
	 * into another string — e.g. appended to the $title argument of
	 * add_settings_field(), which WordPress core echoes raw next to the label.
	 *
	 * The whole trigger+popup pair is wrapped in its own small
	 * `position: relative` anchor span. The popup CSS (.metasync-tooltip) is
	 * `position: absolute; left: 100%` and positions itself relative to the
	 * nearest positioned ancestor — on the main settings accordion that's the
	 * `.metasync-field-label-wrapper` div, but standalone pages (custom
	 * render_callback templates, add_settings_field titles on plain
	 * do_settings_sections() pages, etc.) usually have no such ancestor, so
	 * the popup would escape to whatever distant positioned element exists
	 * on the page (rendering in the wrong corner of the screen). Wrapping
	 * here makes every tooltip self-contained regardless of where it's placed.
	 *
	 * @param string $tooltip_id Unique id for this tooltip (unique per page).
	 * @param string $text       Plain-English help text (escaped internally).
	 * @return string HTML markup for the info-icon trigger + tooltip content.
	 */
	public static function get_tooltip_icon_html($tooltip_id, $text)
	{
		$html  = '<span class="metasync-tooltip-anchor" style="position:relative;display:inline-block;vertical-align:middle;margin-left:8px;">';
		$html .= '<button type="button" class="metasync-tooltip-trigger" data-tooltip-id="' . esc_attr($tooltip_id) . '" aria-label="More information">';
		$html .= '<svg class="metasync-info-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
		$html .= '<circle cx="12" cy="12" r="10"></circle>';
		$html .= '<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>';
		$html .= '<line x1="12" y1="17" x2="12.01" y2="17"></line>';
		$html .= '</svg>';
		$html .= '</button>';

		$html .= '<div class="metasync-tooltip" id="tooltip-' . esc_attr($tooltip_id) . '" role="tooltip">';
		$html .= '<div class="metasync-tooltip-arrow"></div>';
		$html .= '<div class="metasync-tooltip-content">' . esc_html($text) . '</div>';
		$html .= '</div>';
		$html .= '</span>';

		return $html;
	}

	/**
	 * Centralized API Key Event Logging
	 * Provides structured logging for all API key related events with consistent formatting
	 *
	 * @since    1.0.0
	 * @param    string    $event_type     Type of event (change, refresh, reset, etc.)
	 * @param    string    $api_key_type   Type of API key (plugin_auth_token, searchatlas_api_key)
	 * @param    array     $details        Additional details about the event
	 * @param    string    $level          Log level (info, warning, error)
	 */
	public static function log_api_key_event($event_type, $api_key_type, $details = array(), $level = 'info')
	{
		try {
			// Build structured log entry
			$log_data = array(
				'timestamp' => current_time('mysql'),
				'event_type' => $event_type,
				'api_key_type' => $api_key_type,
				'level' => $level
			);
			
			// Add details if provided
			if (!empty($details)) {
				$log_data['details'] = $details;
			}
			
			// Format log message with consistent structure
			$log_prefix = strtoupper($level) . ' - MetaSync API Key Event';
			$log_message = sprintf('[%s] %s: %s (%s)', 
				$log_data['timestamp'],
				$log_prefix,
				$event_type,
				$api_key_type
			);
			
			// Add details to log message if present
			if (!empty($details)) {
				$formatted_details = array();
				foreach ($details as $key => $value) {
					$formatted_details[] = $key . ': ' . (is_string($value) ? $value : json_encode($value));
				}
				$log_message .= ' - ' . implode(', ', $formatted_details);
			}
			
			
			// Optionally store in database for admin dashboard (future enhancement)
			// This could be extended to store in a dedicated log table
			
		} catch (Exception $e) {
			// Fallback logging if structured logging fails
			error_log('MetaSync API Key Event Logging Error: ' . $e->getMessage());
		}
	}

	/**
	 * Handle 404 error monitoring
	 */
	public function handle_404_monitoring()
	{
		// Only process on frontend
		if (is_admin()) {
			return;
		}

		// Check if this is a 404 error
		if (!is_404()) {
			return;
		}

		// PROTECTION 0: Skip WordPress system paths — these are not "broken links"
		$request_uri = $_SERVER['REQUEST_URI'] ?? '';
		$skip_prefixes = [
			'/wp-json/',
			'/wp-admin/',
			'/feed/',
			'/xmlrpc.php',
			'/wp-login.php',
			'/wp-cron.php',
		];
		foreach ($skip_prefixes as $prefix) {
			if (stripos($request_uri, $prefix) === 0) {
				return;
			}
		}

		// PROTECTION 1: Exclude static assets to reduce noise
		$static_extensions = ['.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.ico', '.svg', '.woff', '.woff2', '.ttf', '.eot', '.map','.webp'];
		foreach ($static_extensions as $ext) {
			if (stripos($request_uri, $ext) !== false) {
				return; // Skip logging static asset 404s
			}
		}

		// PROTECTION 2: Bot detection - Block known bot patterns
		$user_agent = $_SERVER['HTTP_USER_AGENT'] ?? '';
		$bot_patterns = ['bot', 'crawler', 'spider', 'scraper', 'curl', 'wget', 'python', 'java'];
		foreach ($bot_patterns as $pattern) {
			if (stripos($user_agent, $pattern) !== false) {
				// Rate limit bot 404s more aggressively
				$bot_rate_key = 'metasync_404_bot_rate';
				$bot_hits = get_transient($bot_rate_key);
				if ($bot_hits !== false && $bot_hits >= 10) {
					// Bot has hit 10+ 404s in last minute - stop logging
					return;
				}
				set_transient($bot_rate_key, $bot_hits === false ? 1 : $bot_hits + 1, 60);
				break;
			}
		}

		// PROTECTION 3: Global rate limiting - Prevent 404 logging storms
		$global_rate_key = 'metasync_404_global_rate';
		$global_hits = get_transient($global_rate_key);
		if ($global_hits !== false && $global_hits >= 50) {
			// More than 50 404s per minute - stop logging to protect database
			if ($global_hits === 50) {
				error_log('MetaSync 404 Monitor: Rate limit exceeded - 50+ 404s per minute. Pausing logging.');
			}
			set_transient($global_rate_key, $global_hits + 1, 60);
			return;
		}
		set_transient($global_rate_key, $global_hits === false ? 1 : $global_hits + 1, 60);

		// Get current URL
		$current_url = $this->get_current_url();

		// PROTECTION 4: Per-URL caching - Prevent same URL from being logged repeatedly
		$url_cache_key = 'metasync_404_cached_' . md5($current_url);
		if (get_transient($url_cache_key)) {
			// This URL was already logged in last 5 minutes - skip DB write
			return;
		}

		// PROTECTION 5: URL validation - Skip obviously malicious URLs
		if (strlen($current_url) > 500 || preg_match('/[<>{}\\\\|]/', $current_url)) {
			return; // Skip potentially malicious or malformed URLs
		}

		// Initialize 404 monitor database
		require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
		$db_404 = new Metasync_Error_Monitor_Database();

		// Get user agent (sanitized)
		$user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field($_SERVER['HTTP_USER_AGENT']) : '';

		// Log the 404 error
		$result = $db_404->update([
			'uri' => $current_url,
			'user_agent' => $user_agent,
			'date_time' => current_time('mysql'),
			'hits_count' => 1
		]);

		// Cache this URL for 5 minutes to prevent repeated DB writes
		set_transient($url_cache_key, true, 300);
	}

	/**
	 * Get current URL
	 */
	private function get_current_url()
	{
		$protocol = is_ssl() ? 'https://' : 'http://';
		
		// Safely get HTTP_HOST with fallback
		$host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';
		if (empty($host) && isset($_SERVER['SERVER_NAME'])) {
			$host = $_SERVER['SERVER_NAME'];
		}
		if (empty($host)) {
			// Fallback to WordPress site URL if available
			$host = parse_url(home_url(), PHP_URL_HOST);
		}
		
		// Safely get REQUEST_URI with fallback
		$uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
		
		// Decode URL-encoded characters
		$uri = urldecode($uri);
		
		// Ensure URI starts with /
		if (!str_starts_with($uri, '/')) {
			$uri = '/' . $uri;
		}
		
		return $protocol . $host . $uri;
	}

	/**
	 * Run the loader to execute all of the hooks with WordPress.
	 *
	 * @since    1.0.0
	 */
	public function run()
	{
		$this->loader->run();
	}

	/**
	 * The name of the plugin used to uniquely identify it within the context of
	 * WordPress and to define internationalization functionality.
	 *
	 * @since     1.0.0
	 * @return    string    The name of the plugin.
	 */
	public function get_plugin_name()
	{
		return $this->plugin_name;
	}

	/**
	 * The reference to the class that orchestrates the hooks with the plugin.
	 *
	 * @since     1.0.0
	 * @return    Metasync_Loader    Orchestrates the hooks of the plugin.
	 */
	public function get_loader()
	{
		return $this->loader;
	}

	/**
	 * Retrieve the version number of the plugin.
	 *
	 * @since     1.0.0
	 * @return    string    The version number of the plugin.
	 */
	public function get_version()
	{
		return $this->version;
	}
}

```
