<?php
/**
 * Plugin Name: TamRank SEO
 * Plugin URI: https://tamrank.com
 * Description: Professional WordPress SEO, surprisingly clean. Meta tags, XML sitemap, schema markup, redirects, 404 monitoring and a clear SEO audit that shows exactly what to fix.
 * Version: 1.3.9
 * Author: TamRank
 * Text Domain: tam-rank
 * Domain Path: /languages
 * Requires at least: 6.0
 * Tested up to: 7.1
 * Requires PHP: 7.4
 * License: GPLv2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 */

// Prevent direct access
if (!defined('ABSPATH')) {
    exit;
}

// Define plugin constants
define('TAM_RANK_VERSION', '1.3.9');
define('TAM_RANK_PLUGIN_URL', plugin_dir_url(__FILE__));
define('TAM_RANK_PLUGIN_PATH', plugin_dir_path(__FILE__));
define('TAM_RANK_PLUGIN_BASENAME', plugin_basename(__FILE__));

// PSR-4 Autoloader for license system
spl_autoload_register(function ($class) {
    $prefix = 'TamRank\\';
    $base_dir = TAM_RANK_PLUGIN_PATH . 'src/';

    $len = strlen($prefix);
    if (strncmp($prefix, $class, $len) !== 0) {
        return;
    }

    $relative_class = substr($class, $len);
    $file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';

    if (file_exists($file)) {
        require $file;
    }
});

// Load helpers FIRST — GSC connector and other early-loaded modules call
// tam_rank_cached_option() in their init hooks; without this, the OAuth
// callback path (which fires on 'init' before load_dependencies() runs)
// would fatal with "Call to undefined function tam_rank_cached_option()".
require_once __DIR__ . '/includes/helpers.php';

// Load i18n early for the same reason: the GSC OAuth callback runs on 'init'
// (before load_dependencies()) and calls tamrank__() — defined in this file —
// 35 times across its handlers. Without this it fatals with "Call to undefined
// function tamrank__()" for a logged-in non-admin hitting the callback, or on a
// malformed properties response. The file only defines the class + global
// functions (the singleton is lazy), so this early require costs nothing until
// the first tamrank__() call.
require_once __DIR__ . '/includes/class-tamrank-i18n.php';

// Load the ownership-proof .well-known handler early (before init) so BOTH its
// init@0 and parse_request@0 hooks register before those actions dispatch.
require_once __DIR__ . '/includes/well-known-verify.php';

// Load the IndexNow key-file handler early (before init) for the same reason:
// its init@0 and parse_request@0 hooks must be registered before those actions
// dispatch. It reads TamRank_IndexNow::get_key() at request time, so the
// feature class has to be available by then too — that file only declares a
// class (the singleton is lazy), so requiring it here costs nothing.
require_once __DIR__ . '/includes/class-tamrank-indexnow.php';
require_once __DIR__ . '/includes/indexnow-key-file.php';

// Load the frontend-output widgets early (before init) for a timing reason:
// wp_widgets_init() fires 'widgets_init' from 'init' priority 1, while
// load_dependencies() runs at priority 10. A widget registered from there would
// be registered after the factory has already collected its list, so it never
// appears on the widget screen. The file only declares classes and hooks
// widgets_init; its constructors call tamrank__(), which is loaded above.
require_once __DIR__ . '/includes/class-tamrank-frontend-widgets.php';

// Load GSC connector early (before init) so its init hook works for OAuth callback
require_once __DIR__ . '/includes/gsc/class-tamrank-gsc-connector.php';

// Main plugin class
class TamRank_Plugin
{

    public function __construct()
    {
        add_action('init', array($this, 'init'));
        register_activation_hook(__FILE__, array($this, 'activate'));
        register_deactivation_hook(__FILE__, array($this, 'deactivate'));
    }

    public function init()
    {
        // Load required files
        $this->load_dependencies();

        // Check for database updates
        $this->maybe_upgrade_database();

        // Fix robots.txt newlines (one-time migration)
        $this->fix_robots_txt_newlines();

        // Initialize components
        $this->init_components();

        // Add admin hooks
        if (is_admin()) {
            $this->init_admin();
        }

        // Frontend hooks
        $this->init_frontend();

        /**
         * Fires after the FREE plugin has fully initialised.
         *
         * The PRO add-on (tam-rank-pro) hooks plugins_loaded (prio 20) and waits
         * for this handshake before registering its own hooks. This decouples the
         * two zips: there is no autoload bond between FREE and PRO.
         *
         * @since 1.2.0
         */
        do_action('tamrank_loaded');
    }

    private function maybe_upgrade_database()
    {
        $installed_ver = get_option('tam_rank_db_version', '0');
        $current_ver   = '1.6.0'; // Bump when adding new tables/columns

        if ( version_compare( $installed_ver, $current_ver, '<' ) ) {
            $this->create_database_tables();
            update_option( 'tam_rank_db_version', $current_ver );
        }
    }

    private function fix_robots_txt_newlines()
    {
        // Restored stub for robots.txt fix
        $robots_txt = get_option('tam_rank_robots_txt');
        if ($robots_txt) {
            // Ensure proper line endings if needed
            // $fixed = str_replace("\r\n", "\n", $robots_txt);
            // if ($fixed !== $robots_txt) update_option('tam_rank_robots_txt', $fixed);
        }
    }

    private function load_dependencies()
    {
        require_once TAM_RANK_PLUGIN_PATH . 'includes/helpers.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-i18n.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-keywords.php';

        // ✅ Feature Registry - Single Source of Truth
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-features.php';

        // ✅ Content Type Config — post type & taxonomy audit/sitemap toggles
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-content-type-config.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-physical-robots.php';

        // ✅ Load API Client FIRST (before license-helpers needs it)
        require_once TAM_RANK_PLUGIN_PATH . 'includes/api/class-api-client.php';
        // Event Dispatcher (WordPress -> VPS post-change notifications) moved to
        // the tam-rank-pro add-on (cluster-15 strip). It is a one-way notifier
        // with no FREE consumer; FREE-only installs no longer ping the VPS on save.
        require_once TAM_RANK_PLUGIN_PATH . 'includes/api/class-tamrank-rest-meta.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/api/class-tamrank-rest-verify.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/api/class-tamrank-rate-limiter.php';

        // ✅ Indexing (auto-index on publish, status column, row actions)
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-indexing.php';

        // Now load license helpers (depends on API Client)
        require_once TAM_RANK_PLUGIN_PATH . 'includes/license-helpers.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/footer.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/sitemap.php';
        // Frontend output: the HTML sitemap reads the XML sitemap's collector,
        // so it has to load after includes/sitemap.php.
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-html-sitemap.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-breadcrumbs.php';
        // Blocks come after both renderers: they read get_defaults() off those
        // classes at registration time. (The widgets for the same two features
        // are required at plugin-load time — see the note near the top of this
        // file about widgets_init firing on init priority 1.)
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-frontend-blocks.php';
        // well-known-verify.php is loaded early at plugin load time (before init)
        // Redirect Chain Detector (PRO): moved to tam-rank-pro. FREE callers in
        // redirect-manager.php guard every use with class_exists() + can_use().
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-redirect-regex.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/redirect-manager.php';
        // Shared "is this 404 already answered by a redirect" rule. Loaded after
        // the manager: it reads the manager's regex cache key. Every screen and
        // the PRO MCP flag go through this one class.
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-404-resolution.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-redirect-suggestions.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-link-graph.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-auto-redirect-on-delete.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-admin-bar-notifications.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-rss.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/seo-audit.php';
        // WebP module
        require_once TAM_RANK_PLUGIN_PATH . 'includes/images/class-webp-generator.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/images/class-webp-bulk.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/images/class-webp-server.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/images/class-webp-module.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/images/class-tamrank-alt-templates.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/images/class-tamrank-alt-bulk.php';

        // Load Schema Builder module (needed for both admin and frontend)
        require_once TAM_RANK_PLUGIN_PATH . 'includes/schema/class-schema-manager.php';

        // ✅ SaaS Architecture: UI Classes (no business logic, API-driven)
        // AI Meta Generator UI (class-meta-ui.php + AJAX + assets) moved to
        // tam-rank-pro (cluster-7a strip); FREE keeps the meta-box fields + the
        // AI buttons gated behind the `tamrank_meta_ai_enabled` filter + the upsell.
        // image-alt UI (Vision Alt Text generator + AJAX) moved to tam-rank-pro
        // (cluster-4 strip); FREE keeps only the media-modal upsell.

        // Topical Authority is a PRO add-on feature; the free plugin ships no
        // topical-authority code, assets or rendering.

        // ✅ TamRank Score Service (Central Calculation Engine)
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-score-service.php';

        // ✅ Score Provider: Read-only score aggregator for dashboard
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-score-provider.php';

        // Priority Engine (impact-ranked task cards) moved to the tam-rank-pro
        // add-on (cluster-10 strip). FREE renders the rest of the SEO Tasks list
        // and pulls the priority data via the tamrank_priority_data filter, which
        // only PRO answers (gated on the priority_actions licence).

        // Headless priority-cache warm-up contract (open-loop
        // mcp-coldstart-helper-rest-unreachable). TamRank_Dashboard lives in
        // admin/dashboard.php, which only loads under is_admin(); on a /wp-json/
        // request the class never exists, so PRO's MCP cold-start cannot reach
        // compute_priority_actions(). This action is the stable cross-add-on
        // contract: PRO calls do_action('tamrank_warm_priority_cache') instead of
        // a method_exists() probe, and FREE lazy-requires the admin file only when
        // the hook actually fires (cold cache) — no per-REST-call cost, no file-path
        // coupling. The warm-up runs the same chain render_dashboard() uses and
        // writes the tamrank_priority_actions transient via the PRO filter.
        add_action('tamrank_warm_priority_cache', function () {
            if (!class_exists('TamRank_Dashboard')) {
                require_once TAM_RANK_PLUGIN_PATH . 'admin/dashboard.php';
            }
            if (method_exists('TamRank_Dashboard', 'compute_priority_actions')) {
                TamRank_Dashboard::compute_priority_actions();
            }
        });

        // Change Tracker moved to the tam-rank-pro add-on (cluster-6 strip); PRO
        // boots it on tamrank_loaded. The shared change-log table is still created
        // by FREE on activation; readers here use class_exists() guards.

        // ✅ Outdated Content Detection Engine
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-outdated-detector.php';
        require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-outdated-scanner.php';

        // Note: GSC Connector is loaded at plugin file level (before init hook)

        if (is_admin()) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-onboarding.php';
            require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-tour.php';
            require_once TAM_RANK_PLUGIN_PATH . 'admin/meta-boxes.php';
            require_once TAM_RANK_PLUGIN_PATH . 'admin/class-tamrank-taxonomy-meta.php';
            require_once TAM_RANK_PLUGIN_PATH . 'admin/settings-page.php';
            require_once TAM_RANK_PLUGIN_PATH . 'admin/class-tamrank-server-rules-editor.php';
            // PageSpeed (admin/pagespeed.php + includes/pagespeed/) moved to the
            // tam-rank-pro add-on during the cluster-1 Free/PRO strip.
            require_once TAM_RANK_PLUGIN_PATH . 'admin/audit-page.php';

            // Load Media Library Integration
            require_once TAM_RANK_PLUGIN_PATH . 'admin/media-library-integration.php';
        }
    }

    private function init_components()
    {
        // Initialize freemium manager (must be first for feature checks)
        \TamRank\FreemiumManager::get_instance();

        // License manager is no longer instantiated at boot. The write side
        // (activate / deactivate / admin notices) + the custom UpdateChecker moved
        // to the tam-rank-pro add-on (cluster-15 strip); FREE updates via wp.org.
        // FREE's transport/read LicenseManager is still used on demand via local
        // `new \TamRank\LicenseManager()` (GSC connector, settings read).

        // Initialize sitemap. Kept in a global because the HTML sitemap reads
        // its collector; constructing a second instance would register every
        // sitemap hook twice.
        global $tam_rank_sitemap;
        $tam_rank_sitemap = new TamRank_Sitemap();

        // Frontend output: [tamrank_sitemap] and [tamrank_breadcrumb] (both also
        // available as tamrank_the_sitemap() / tamrank_the_breadcrumbs(), as a
        // block and as a widget — four routes, one renderer each).
        new TamRank_HTML_Sitemap();
        new TamRank_Breadcrumbs();
        new TamRank_Frontend_Blocks();

        // Ownership-proof challenge handler (/.well-known/tamrank-challenge.txt) is
        // self-instantiated at file-load time (see includes/well-known-verify.php),
        // mirroring indexnow-key-file.php, so its init@0 + parse_request@0 hooks
        // both fire.

        // Initialize WebP components
        new TamRank_WebP_Module();
        new TamRank_WebP_Bulk();
        new TamRank_WebP_Server();

        // Automatic alt/title templates on upload (off by default)
        new TamRank_Alt_Templates();

        // The same fill over media that is already in the library. Its own
        // switch, also off by default, and independent of the one above: the
        // routes are registered here but refuse while that switch is off.
        new TamRank_Alt_Bulk();

        // Initialize redirect manager
        // This feature might act as a core feature or toggleable in future, assumed enabled/core for now or manage internal state
        global $tam_rank_redirect_manager;
        $tam_rank_redirect_manager = new TamRank_Redirect_Manager();

        // Initialize redirect suggestions
        global $tam_rank_redirect_suggestions;
        $tam_rank_redirect_suggestions = new TamRank_Redirect_Suggestions();

        // Initialize internal link graph (orphan detection, weekly cron + AJAX)
        global $tam_rank_link_graph;
        $tam_rank_link_graph = new TamRank_Link_Graph();

        // Initialize auto-redirect on delete (feature-gated via constructor)
        new TamRank_Auto_Redirect_On_Delete();

        // RSS feed optimisation (off by default; the filters no-op when disabled)
        new TamRank_RSS();

        // ✅ CONDITIONAL: Initialize admin bar 404 notifications
        if (TamRank_Features::is_enabled('404_monitoring')) {
            global $tam_rank_admin_bar_notifications;
            $tam_rank_admin_bar_notifications = new TamRank_Admin_Bar_Notifications();
        }

        // Initialize Schema Builder (needed for frontend rendering too)
        global $tamrank_schema;
        $tamrank_schema = new TAMRANK_Schema_Manager();

        // Topical Authority manager moved to tam-rank-pro (cluster-2 strip); PRO
        // instantiates it on the tamrank_loaded handshake.

        // ✅ Indexing: GSC indexing auto-submit + manual (fire-and-forget) request.
        // The index status column, banners and the bulk-scan engine moved to the
        // tam-rank-pro add-on (cluster-5 strip); PRO boots them on tamrank_loaded.
        TamRank_Indexing::get_instance();

        // ✅ IndexNow: instant notification to Bing, Yandex, Naver, Seznam,
        // Amazon and Yep on publish/update. A separate path from the Google
        // indexing request above — neither replaces the other. Off by default;
        // the class file is required early (see the top of this file) because
        // the key-file handler runs at init@0.
        TamRank_IndexNow::get_instance();

        // Change Tracker instantiation moved to tam-rank-pro (cluster-6 strip).

        // REST API — Core SEO Metadata Endpoints (must be outside is_admin())
        $rest_meta = new TamRank_REST_Meta();
        add_action( 'rest_api_init', [ $rest_meta, 'register_routes' ] );

        // REST API — public site-verify route (REST fallback for the ownership
        // -proof nonce when /.well-known is blocked). Outside is_admin() so it
        // resolves on front-end REST requests.
        $rest_verify = new TamRank_REST_Verify();
        add_action( 'rest_api_init', [ $rest_verify, 'register_routes' ] );
    }

    private function init_admin()
    {
        // ✅ Initialize i18n FIRST so filters are active before other classes use __()
        TamRank_i18n::get_instance();

        // Initialize onboarding wizard
        new TamRank_Onboarding();

        // Initialize admin components

        // ✅ CONDITIONAL: Meta Boxes
        if (TamRank_Features::is_enabled('meta_boxes')) {
            new TamRank_Meta_Boxes();
            new TamRank_Taxonomy_Meta();
        }

        new TamRank_Settings_Page();

        // .htaccess Editor (FREE) — registers its own AJAX save/restore handlers.
        new TamRank_Htaccess_Editor();

        // PageSpeed engine + queue moved to the tam-rank-pro add-on (cluster-1
        // Free/PRO strip). PRO instantiates them on the tamrank_loaded handshake.

        // ✅ CONDITIONAL: SEO Audit Page
        if (TamRank_Features::is_enabled('seo_audit')) {
            new TamRank_Audit_Page();
            new TamRank_SEO_Audit();
        }

        // GSC Page Insights (Page Insights + Position Stability + Keyword Potential)
        // moved to the tam-rank-pro add-on during the cluster-3 Free/PRO strip.
        // PRO re-registers TamRank_GSC_Pages (AJAX handlers + page) on tamrank_loaded.

        // Initialize Content Tab for content analysis
        require_once plugin_dir_path(__FILE__) . 'admin/content/class-tamrank-content-tab.php';
        TamRank_Content_Tab::get_instance();

        // ✅ Initialize UI components (SaaS architecture)
        // TamRank_Meta_UI moved to tam-rank-pro (cluster-7a strip).
        // TamRank_Image_Alt_UI moved to tam-rank-pro (cluster-4 strip).

        // ✅ Outdated Content Scanner (AJAX batch runner)
        TamRank_Outdated_Scanner::get_instance();

        // Initialize Media Library Integration
        tamrank_media_library_integration();

        // License admin page (activate / deactivate AJAX + license-tab active-state
        // render) moved to the tam-rank-pro add-on (cluster-15 strip).

        // Initialize PRO feature tour
        TamRank_Tour::get_instance();

        // Register the shared common/ stylesheets before anything enqueues them
        // (priority 1, so every later callback can name them as a dependency).
        add_action('admin_enqueue_scripts', array($this, 'register_common_styles'), 1);

        // Add admin styles and scripts
        add_action('admin_enqueue_scripts', array($this, 'admin_enqueue_scripts'));

        // Add plugin action links
        add_filter('plugin_action_links_' . TAM_RANK_PLUGIN_BASENAME, array($this, 'plugin_action_links'));

        // Add a "data settings" hint in the plugins-list row meta. This is the
        // closest WordPress lets us get to the delete moment (there is no
        // pre-delete confirmation hook), so it points users at the Privacy &
        // Data controls before they remove the plugin.
        add_filter('plugin_row_meta', array($this, 'plugin_row_meta'), 10, 2);

        // Add admin bar menu
        add_action('admin_bar_menu', array($this, 'add_admin_bar_menu'), 80);
        add_action('wp_enqueue_scripts', array($this, 'admin_bar_styles'));
        add_action('admin_enqueue_scripts', array($this, 'admin_bar_styles'));
    }

    private function init_frontend()
    {
        // Document title via core filters
        add_filter('pre_get_document_title', array($this, 'tam_rank_filter_document_title'), 20);
        add_filter('document_title_parts', array($this, 'tam_rank_filter_document_title_parts'), 20);

        // Core prints its own rel=canonical on wp_head priority 10, so the
        // decision to step aside has to be made before that - our own tags run
        // at 20, which is too late to remove core's action.
        add_action('wp_head', array($this, 'tam_rank_maybe_remove_core_canonical'), 9);

        // Meta tags in head (zonder <title>)
        add_action('wp_head', array($this, 'output_meta_tags'), 20);
        // Late priority: we take ownership of the directives we set, after core
        // and other plugins have contributed theirs.
        add_filter('wp_robots', array($this, 'filter_robots'), 99);

        // Fallback: alleen <title> als theme geen title-tag support heeft
        if (!current_theme_supports('title-tag')) {
            add_action('wp_head', function () {
                $t = $this->tam_rank_get_custom_meta_title_for_context();
                if (!empty($t)) {
                    echo '<title>' . esc_html($t) . "</title>\n";
                }
            }, 1);
        }

        add_action('send_headers', array($this, 'tam_rank_send_robots_header'));

        // Custom robots.txt - use priority 999 to override WordPress default
        add_filter('robots_txt', array($this, 'custom_robots_txt'), 999, 2);

        // Monitor 404 errors
        add_action('template_redirect', array($this, 'monitor_404'));
    }

    /**
     * Register the shared assets/css/common/ stylesheets as versioned handles.
     *
     * These used to be pulled in with @import from tamrank-admin-global.css and
     * from a dozen page stylesheets. An @import URL inherits no ?ver= from its
     * parent, so after a plugin update the browser kept serving the cached
     * sub-CSS (max-age=14400) next to the fresh PHP. Enqueueing them puts
     * TAM_RANK_VERSION in every URL, so an update busts all of them at once.
     *
     * The chain mirrors the old @import order exactly: each file depends on the
     * previous one, so WP prints them in the same sequence @import produced, and
     * tamrank-admin-global depends on the tail of the chain so the whole set
     * still lands before its own rules.
     *
     * Registration only — nothing is enqueued here. A handle that nobody names
     * as a dependency is never printed.
     */
    public function register_common_styles()
    {
        $base  = TAM_RANK_PLUGIN_URL . 'assets/css/common/';
        $chain = array(
            'tamrank-variables'       => 'variables.css',
            'tamrank-buttons'         => 'buttons.css',
            'tamrank-tabs'            => 'tabs.css',
            'tamrank-notifications'   => 'notifications.css',
            'tamrank-redirect-prompt' => 'redirect-prompt.css',
            'tamrank-tooltip-css'     => 'tooltip.css',
            'tamrank-motion'          => 'motion.css',
        );

        $prev = array();
        foreach ($chain as $handle => $file) {
            wp_register_style($handle, $base . $file, $prev, TAM_RANK_VERSION);
            $prev = array($handle);
        }

        // Second, late registration of the very same file. Several page
        // stylesheets used to @import variables.css *after* admin.css had
        // loaded, and admin.css redeclares eight tokens (--tr-radius-sm,
        // --tr-text-xs..2xl, --tr-shadow-hover) with different values. That
        // re-import is what decides the winner on those screens, so dropping it
        // would silently change the type scale. This handle reproduces it as a
        // versioned request: it depends on tam-rank-admin, so it always prints
        // after admin.css, exactly where the @import used to sit.
        wp_register_style(
            'tamrank-variables-late',
            $base . 'variables.css',
            array('tam-rank-admin'),
            TAM_RANK_VERSION
        );
    }

    public function admin_enqueue_scripts($hook)
    {
        // Only load on our admin pages
        $is_tamrank_page = strpos($hook, 'tam-rank') !== false
            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only admin-page detection for asset enqueue; no state-changing action to protect.
            || (isset($_GET['page']) && strpos(sanitize_text_field(wp_unslash($_GET['page'])), 'tam-rank') !== false)
            || in_array($hook, ['post.php', 'post-new.php', 'edit.php', 'term.php', 'edit-tags.php']);
        if ($is_tamrank_page) {
            // Enqueue Global Design System (Variables + Fonts + Component Resets)
            wp_enqueue_style(
                'tamrank-admin-global',
                TAM_RANK_PLUGIN_URL . 'assets/css/tamrank-admin-global.css',
                array('tamrank-motion'), // tail of the common/ chain (see register_common_styles)
                TAM_RANK_VERSION
            );

            wp_enqueue_style(
                'tam-rank-admin',
                TAM_RANK_PLUGIN_URL . 'assets/css/admin.css',
                array('tamrank-admin-global'), // Depends on global
                TAM_RANK_VERSION
            );

            // License styles (license page + settings license tab)
            if (isset($_GET['page']) && in_array(sanitize_text_field(wp_unslash($_GET['page'])), array('tam-rank-license', 'tam-rank-settings'), true)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request inspection (admin page / OAuth callback / menu detection); no state-changing action to protect.
                wp_enqueue_style(
                    'tamrank-license',
                    TAM_RANK_PLUGIN_URL . 'assets/css/tamrank-license.css',
                    array('tamrank-admin-global', 'tamrank-variables-late'),
                    TAM_RANK_VERSION
                );
            }

            // Status card at the top of the robots.txt section on the Technical
            // SEO tab. Presentation only, settings page only - it renders what
            // the existing checks already decided and adds no behaviour of its own.
            if ($hook === 'tam-rank_page_tam-rank-settings') {
                wp_enqueue_style(
                    'tamrank-robots-status',
                    TAM_RANK_PLUGIN_URL . 'assets/css/tamrank-robots-status.css',
                    array('tamrank-admin-global', 'tamrank-variables-late'),
                    TAM_RANK_VERSION
                );
            }

            // Index Status column styles moved to tam-rank-pro (cluster-5 strip);
            // PRO enqueues them on edit.php when the column is licensed + active.

            // Dashboard specific styles
            if ($hook === 'toplevel_page_tam-rank-dashboard') {
                wp_enqueue_style(
                    'tamrank-dashboard',
                    TAM_RANK_PLUGIN_URL . 'assets/css/tamrank-dashboard.css',
                    array('tamrank-admin-global', 'tamrank-variables-late'),
                    TAM_RANK_VERSION
                );
            }

            // 404 Monitor specific styles
            if ($hook === 'tam-rank_page_tam-rank-404') {
                wp_enqueue_style(
                    'tamrank-404-monitor',
                    TAM_RANK_PLUGIN_URL . 'assets/css/tamrank-404-monitor.css',
                    array('tamrank-admin-global', 'tamrank-variables-late'),
                    TAM_RANK_VERSION
                );
            }

            // Redirect Manager specific styles
            if ($hook === 'tam-rank_page_tam-rank-redirects') {
                wp_enqueue_style(
                    'tamrank-redirect-manager',
                    TAM_RANK_PLUGIN_URL . 'assets/css/tamrank-redirect-manager.css',
                    array('tamrank-admin-global', 'tamrank-variables-late'),
                    TAM_RANK_VERSION
                );
            }

            // Schema Builder specific styles and scripts
            if ($hook === 'tam-rank_page_tam-rank-schema') {
                wp_enqueue_style(
                    'tamrank-schema-builder',
                    TAM_RANK_PLUGIN_URL . 'assets/css/tamrank-schema-builder.css',
                    array('tamrank-admin-global'),
                    TAM_RANK_VERSION
                );
                wp_enqueue_script(
                    'tamrank-schema-builder',
                    TAM_RANK_PLUGIN_URL . 'assets/js/tamrank-schema-builder.js',
                    array('jquery', 'tam-rank-toast'),
                    TAM_RANK_VERSION,
                    true
                );
                wp_enqueue_media();
                wp_localize_script('tamrank-schema-builder', 'tamrankSchemaBuilder', array(
                    'strings' => array(
                        'confirm_delete_template'   => tamrank__('confirm_delete_template', 'Are you sure you want to delete this template?'),
                        'fill_url'                  => tamrank__('fill_url', 'Enter a URL'),
                        'template_not_found'        => tamrank__('template_not_found', 'Template not found'),
                        'fill_template_name'        => tamrank__('fill_template_name', 'Enter a template name'),
                        'choose_schema_type'        => tamrank__('choose_schema_type', 'Choose a schema type'),
                        'select_post_type'          => tamrank__('select_post_type', 'Select at least one post type'),
                        'saving'                    => tamrank__('saving', 'Saving...'),
                        'template_save_btn'         => tamrank__('template_save_btn', 'Save Template'),
                        'template_saved'            => tamrank__('template_saved', 'Template saved'),
                        'error_save'                => tamrank__('error_save', 'Error saving'),
                        'error_server'              => tamrank__('error_server', 'Server not reachable'),
                        'no_templates'              => tamrank__('no_templates', 'No templates created yet.'),
                        'template_deleted'          => tamrank__('template_deleted', 'Template deleted'),
                        'template_deleted_desc'     => tamrank__('template_deleted_desc', 'The template was successfully deleted.'),
                        'error_delete'              => tamrank__('error_delete', 'Error deleting'),
                        'choose_logo'               => tamrank__('choose_logo', 'Choose a logo'),
                        'use_as_logo'               => tamrank__('use_as_logo', 'Use as logo'),
                        'choose_social_image'       => tamrank__('choose_social_image', 'Choose a fallback image'),
                        'use_as_social_image'       => tamrank__('use_as_social_image', 'Use as fallback image'),
                        'fill_jsonld'               => tamrank__('fill_jsonld', 'Enter JSON-LD code'),
                        'validating'                => tamrank__('validating', 'Validating...'),
                        'validation_success'        => tamrank__('validation_success', 'Validation successful'),
                        'validation_failed'         => tamrank__('validation_failed', 'Validation failed'),
                        'validation_error'          => tamrank__('validation_error', 'Validation error'),
                        'settings_save_btn'         => tamrank__('settings_save_btn', 'Save Settings'),
                        'settings_saved'            => tamrank__('settings_saved', 'Settings saved'),
                        'settings_saved_desc'       => tamrank__('settings_saved_desc', 'Global schema settings have been updated.'),
                        'error_save_settings'       => tamrank__('error_save_settings', 'Error saving settings'),
                        'schema_theme_fallback'     => tamrank__('schema_theme', 'Schema theme'),
                        'unknown_error'             => tamrank__('unknown_error', 'Unknown error'),
                        'no_field_config'           => tamrank__('schema_no_field_config', 'No field configuration available for this type.'),
                        'no_fields_section'         => tamrank__('schema_no_fields_section', 'No fields in this section.'),
                        'template_saved_suffix'     => tamrank__('schema_template_saved_suffix', ' was saved successfully.'),
                        'delete_template_title'     => tamrank__('schema_delete_template_title', 'Delete template?'),
                        'delete'                    => tamrank__('delete', 'Delete'),
                        'validate_jsonld_btn'       => tamrank__('schema_validate_jsonld_btn', 'Validate JSON-LD'),
                        'jsonld_valid'              => tamrank__('schema_jsonld_valid', 'JSON-LD is valid.'),
                        'warnings_suffix'           => tamrank__('schema_warnings_suffix', 'warning(s).'),
                        'warnings_label'            => tamrank__('schema_warnings_label', 'Warnings:'),
                        // LocalBusiness workbench
                        'lb_copy_msg_closed'        => tamrank__('lb_copy_msg_closed', 'Monday is marked closed, so there are no opening hours to copy. Nothing has changed.'),
                        'lb_copy_msg_incomplete'    => tamrank__('lb_copy_msg_incomplete', 'Monday\'s time is only half filled in — the hours or the minutes are still missing. Nothing has been copied, so the other days keep their times.'),
                        'lb_copy_msg_empty'         => tamrank__('lb_copy_msg_empty', 'Monday has no opening and closing time yet. Fill both in first; nothing has been copied, so the other days keep their times.'),
                        'lb_copy_msg_done_week'     => tamrank__('lb_copy_msg_done_week', 'Copied %s to Tuesday through Friday.'),
                        'lb_copy_msg_done_all'      => tamrank__('lb_copy_msg_done_all', 'Copied %s to every other day.'),
                        'lb_visit_sub_on'           => tamrank__('lb_visit_sub_on', 'Your address ships as a PostalAddress — check the output on the right.'),
                        'lb_visit_sub_off'          => tamrank__('lb_visit_sub_off', 'Your address stays saved, but does not ship. Only your service area does.'),
                        'lb_show_all'               => tamrank__('lb_show_all', 'Show all'),
                        'lb_collapse'               => tamrank__('lb_collapse', 'Collapse'),
                        'lb_click_open'             => tamrank__('lb_click_open', 'click to open'),
                        'lb_dropped'                => tamrank__('lb_dropped', 'just dropped out of the output'),
                        'lb_item'                   => tamrank__('lb_item', 'item'),
                        'lb_items'                  => tamrank__('lb_items', 'items'),
                        'lb_fields'                 => tamrank__('lb_fields', 'fields'),
                        'lb_nothing_yet'            => tamrank__('lb_nothing_yet', 'Nothing filled in yet'),
                        'lb_profile_single'         => tamrank__('lb_profile_single', 'profile'),
                        'lb_profile_plural'         => tamrank__('lb_profile_plural', 'profiles'),
                        'lb_linked'                 => tamrank__('lb_linked', 'linked'),
                        'lb_cov_name'               => tamrank__('lb_cov_name', 'name — business name'),
                        'lb_cov_address'            => tamrank__('lb_cov_address', 'address — full street address'),
                        'lb_cov_area'               => tamrank__('lb_cov_area', 'areaServed — service area (address is off)'),
                        'lb_pick_media'             => tamrank__('lb_pick_media', 'Choose media'),
                    )
                ));
            }

            // PageSpeed CSS/JS moved to the tam-rank-pro add-on (cluster-1 strip).
            // PRO enqueues them on its own page; the global tamRank localize
            // object below still feeds the PageSpeed script with nonce + i18n.

            // SEO Audit specific styles and scripts
            if ($hook === 'tam-rank_page_tam-rank-audit') {
                wp_enqueue_style(
                    'tamrank-seo-audit',
                    TAM_RANK_PLUGIN_URL . 'assets/css/tamrank-seo-audit.css',
                    array('tamrank-admin-global'),
                    TAM_RANK_VERSION
                );
                wp_enqueue_script(
                    'tamrank-seo-audit',
                    TAM_RANK_PLUGIN_URL . 'assets/js/tamrank-seo-audit.js',
                    array('tamrank-reveal'),
                    TAM_RANK_VERSION,
                    true
                );
                // The Bulk Meta / AI / save strings (tamrankSeoAudit localize)
                // moved to the tam-rank-pro add-on (cluster-13 strip); PRO
                // localizes them onto its own tamrank-audit-pro.js handle. FREE's
                // audit script reads only the #tr-audit-data JSON block now.
            }

            // GSC Page Insights styles/scripts moved to the tam-rank-pro add-on
            // (cluster-3 strip); PRO enqueues them on its own page hook.

            wp_enqueue_script(
                'tam-rank-toast',
                TAM_RANK_PLUGIN_URL . 'assets/js/tam-toast.js',
                array('jquery'),
                TAM_RANK_VERSION,
                true
            );

            wp_enqueue_script(
                'tamrank-tooltip',
                TAM_RANK_PLUGIN_URL . 'assets/js/tr-tooltip.js',
                array(),
                TAM_RANK_VERSION,
                true
            );

            // Gedeelde motion & feedback-laag (Branding.md §Motion & Feedback);
            // de CSS-kant rijdt mee via common/motion.css in tamrank-admin-global.
            wp_enqueue_script(
                'tamrank-reveal',
                TAM_RANK_PLUGIN_URL . 'assets/js/tr-reveal.js',
                array(),
                TAM_RANK_VERSION,
                true
            );

            wp_enqueue_script(
                'tam-rank-admin',
                TAM_RANK_PLUGIN_URL . 'assets/js/admin.js',
                array('jquery', 'tam-rank-toast'),
                TAM_RANK_VERSION,
                true
            );

            $general_settings = get_option('tam_rank_general_settings', array());
            wp_localize_script('tam-rank-admin', 'tamRank', array(
                'isPro' => tamrank_is_pro(),
                'ajaxUrl' => admin_url('admin-ajax.php'),
                'nonce' => wp_create_nonce('tam_rank_nonce'),
                'buyCreditsUrl' => 'https://tamrank.com/account/?tab=buy-credits&site=' . rawurlencode(home_url()),
                'autoIndexing' => !empty($general_settings['enable_auto_indexing']),
                'pluginUrl' => TAM_RANK_PLUGIN_URL,
                'i18n' => array(
                    'buy_credits' => tamrank__('error_credits_buy_more', 'Buy extra credits'),
                    'seo_score' => tamrank__('seo_score', 'SEO Score'),
                    'analysis_details' => tamrank__('analysis_details', 'Analysis Details'),
                    'check' => tamrank__('check', 'Check'),
                    'status' => tamrank__('status', 'Status'),
                    'score' => tamrank__('score', 'Score'),
                    'start_seo_audit' => tamrank__('start_seo_audit', 'Start SEO Audit'),
                    'mobile' => tamrank__('mobile', 'Mobile'),
                    'desktop' => tamrank__('desktop', 'Desktop'),
                    'excellent' => tamrank__('excellent', 'Excellent'),
                    'good' => tamrank__('good', 'Good'),
                    'needs_attention' => tamrank__('needs_attention', 'Needs Attention'),
                    'needs_improvement' => tamrank__('needs_improvement', 'Needs Improvement'),
                    'lcp_element_heading' => tamrank__('lcp_element_heading', 'LCP Element (cause of slow LCP)'),
                    'load_timeline' => tamrank__('load_timeline', 'Load timeline'),
                    'screenshot_at' => tamrank__('screenshot_at', 'Screenshot at %ss'),
                    'loading' => tamrank__('loading', 'Loading...'),
                    'opportunities' => tamrank__('opportunities', 'Opportunities'),
                    'diagnostics' => tamrank__('diagnostics', 'Diagnostics'),
                    'passed_audits' => tamrank__('passed_audits', 'Passed audits'),
                    'items' => tamrank__('items', 'items'),
                    'savings' => tamrank__('savings', 'savings'),
                    'core_vital' => tamrank__('core_vital', 'Core Vital'),
                    'enter_url' => tamrank__('enter_url', 'Enter a URL to test.'),
                    'invalid_url' => tamrank__('invalid_url', 'Enter a valid URL.'),
                    'testing_progress' => tamrank__('testing_progress', 'Testing...'),
                    'test_failed' => tamrank__('test_failed', 'Test could not be performed'),
                    'test_again' => tamrank__('test_again', 'Test Again'),
                    'force_new_test' => tamrank__('force_new_test', 'Force New Test'),
                    'test_pagespeed' => tamrank__('test_pagespeed', 'Test PageSpeed'),
                    'error_occurred' => tamrank__('error_occurred', 'An error occurred'),
                    'analyzing' => tamrank__('analyzing', 'Analyzing...'),
                    'what_does_this_mean' => tamrank__('what_does_this_mean', 'What does this mean?'),
                    'concrete_steps' => tamrank__('concrete_steps', 'Concrete steps'),
                    'pages_tested' => tamrank__('pages_tested', 'pages tested'),
                    'estimated_time' => tamrank__('estimated_time', 'Estimated time remaining'),
                    'pages' => tamrank__('pages', 'pages'),
                    'bulk_error' => tamrank__('bulk_error', 'Could not start bulk scan.'),
                    'analyzing_page' => tamrank__('analyzing_page', 'Analyzing page 1...'),
                    'test_error' => tamrank__('test_error', 'An error occurred while testing.'),
                    'n_a' => tamrank__('n_a', 'N/A'),
                    'at_time' => tamrank__('at_time', 'at'),
                    'no_detail_data' => tamrank__('no_detail_data', 'No detailed data available. Test this URL again.'),
                    'last_test_results' => tamrank__('last_test_results', 'Last test results'),
                    'tested_on' => tamrank__('tested_on', 'Tested on'),
                    'view' => tamrank__('view', 'View'),
                    'export_csv' => tamrank__('export_csv', 'Export CSV'),
                    'metric_lcp' => tamrank__('metric_lcp', 'Largest Contentful Paint'),
                    'metric_inp' => tamrank__('metric_inp', 'Interaction to Next Paint'),
                    'metric_cls' => tamrank__('metric_cls', 'Cumulative Layout Shift'),
                    'metric_fcp' => tamrank__('metric_fcp', 'First Contentful Paint'),
                    'metric_tbt' => tamrank__('metric_tbt', 'Total Blocking Time'),
                    'metric_si' => tamrank__('metric_si', 'Speed Index'),
                    'default_audit_explanation' => tamrank__('default_audit_explanation', 'This item can be optimized for better performance.'),
                    'default_audit_steps' => tamrank__('default_audit_steps', 'Analyze the issue|Implement the recommended fix|Test the improvement'),
                    'lcp_description' => tamrank__('lcp_description', 'Measures when the largest visible element has loaded.'),
                    'lcp_tip' => tamrank__('lcp_tip', 'Optimize hero images and use preload.'),
                    'inp_description' => tamrank__('inp_description', 'Measures how quickly the page responds to user input.'),
                    'inp_tip' => tamrank__('inp_tip', 'Split long JavaScript tasks.'),
                    'cls_description' => tamrank__('cls_description', 'Measures how much the layout shifts during load.'),
                    'cls_tip' => tamrank__('cls_tip', 'Give images and ads fixed dimensions.'),
                    'fcp_description' => tamrank__('fcp_description', 'Measures when the first content is visible.'),
                    'fcp_tip' => tamrank__('fcp_tip', 'Reduce render-blocking resources.'),
                    'tbt_description' => tamrank__('tbt_description', 'Measures how long the main thread is blocked.'),
                    'tbt_tip' => tamrank__('tbt_tip', 'Remove unused JavaScript.'),
                    'si_description' => tamrank__('si_description', 'Measures how quickly content is visually displayed.'),
                    'si_tip' => tamrank__('si_tip', 'Optimize the critical rendering path.'),
                    // CrUX Real User Data
                    'crux_simulated' => tamrank__('crux_simulated', 'Simulated'),
                    'crux_real_visitors' => tamrank__('crux_real_visitors', 'Real visitors'),
                    'crux_site_average' => tamrank__('crux_site_average', 'Site average'),
                    'crux_origin_badge' => tamrank__('crux_origin_badge', 'Site-wide'),
                    'crux_field_warning' => tamrank__('crux_field_warning', 'Real visitors experience slower loading. Google uses this data for rankings.'),
                    'crux_no_field_data' => tamrank__('crux_no_field_data', 'No real visitor data available yet'),
                    'crux_origin_hint' => tamrank__('crux_origin_hint', 'Site-wide average. Page-level data needs more traffic.'),
                    'crux_details' => tamrank__('crux_details', 'Details'),
                    'crux_distribution' => tamrank__('crux_distribution', 'Visitor distribution'),
                    'crux_good' => tamrank__('crux_good', 'good'),
                    'crux_needs_improvement' => tamrank__('crux_needs_improvement', 'needs improvement'),
                    'crux_poor' => tamrank__('crux_poor', 'poor'),
                    'crux_most_visitors' => tamrank__('crux_most_visitors', 'Experience of most visitors'),
                    'crux_source_page' => tamrank__('crux_source_page', 'Based on real visitors to this page'),
                    'crux_source_origin' => tamrank__('crux_source_origin', 'Based on all pages of this site'),
                    'audit_render_blocking_expl' => tamrank__('audit_render_blocking_expl', 'Resources block the page from rendering before the first content is visible.'),
                    'audit_render_blocking_steps' => tamrank__('audit_render_blocking_steps', 'Defer non-critical JavaScript|Inline critical CSS|Use async for scripts'),
                    'audit_unused_css_expl' => tamrank__('audit_unused_css_expl', 'CSS is loaded that is not used on this page.'),
                    'how_to_fix' => tamrank__('How to fix this?', 'How to fix this?'),
                    'audit_unused_css_steps' => tamrank__('audit_unused_css_steps', 'Remove unused CSS|Split CSS per page|Use PurgeCSS or similar tools'),
                    'audit_unused_js_expl' => tamrank__('audit_unused_js_expl', 'JavaScript is shipped but not executed on this page.'),
                    'audit_unused_js_steps' => tamrank__('audit_unused_js_steps', 'Remove unused scripts|Apply code splitting|Lazy load non-critical JS'),
                    'audit_optimized_images_expl' => tamrank__('audit_optimized_images_expl', 'Images can be smaller without losing quality.'),
                    'audit_optimized_images_steps' => tamrank__('audit_optimized_images_steps', 'Compress images with tools like TinyPNG|Use efficient formats (WebP)|Serve images in the right dimensions'),
                    'audit_webp_images_expl' => tamrank__('audit_webp_images_expl', 'WebP offers better compression than PNG or JPEG.'),
                    'audit_webp_images_steps' => tamrank__('audit_webp_images_steps', 'Convert images to WebP|Use the picture element for fallbacks|Configure your server to serve WebP'),
                    'audit_offscreen_images_expl' => tamrank__('audit_offscreen_images_expl', 'Images below the fold are loaded immediately.'),
                    'audit_offscreen_images_steps' => tamrank__('audit_offscreen_images_steps', 'Enable lazy loading (loading=\"lazy\")|Use Intersection Observer API|Prioritize above-the-fold images'),
                    'audit_text_compression_expl' => tamrank__('audit_text_compression_expl', 'Text files are not being sent compressed.'),
                    'audit_text_compression_steps' => tamrank__('audit_text_compression_steps', 'Enable GZIP or Brotli compression|Configure this in .htaccess or nginx|Verify settings with your host'),
                    'audit_cache_ttl_expl' => tamrank__('audit_cache_ttl_expl', 'Static assets have missing or short cache headers.'),
                    'audit_cache_ttl_steps' => tamrank__('audit_cache_ttl_steps', 'Set cache headers for static assets|Use versioning for cache busting|Configure browser caching'),
                    'audit_server_response_expl' => tamrank__('audit_server_response_expl', 'The server responds slowly to requests (TTFB).'),
                    'audit_server_response_steps' => tamrank__('audit_server_response_steps', 'Optimize database queries|Use server-side caching|Consider faster hosting'),
                    'audit_dom_size_expl' => tamrank__('audit_dom_size_expl', 'Too many DOM nodes slow down rendering and interaction.'),
                    'audit_dom_size_steps' => tamrank__('audit_dom_size_steps', 'Simplify the HTML structure|Virtualize long lists|Remove unnecessary wrappers'),
                    'audit_bootup_time_expl' => tamrank__('audit_bootup_time_expl', 'JavaScript takes too long to parse and execute.'),
                    'audit_bootup_time_steps' => tamrank__('audit_bootup_time_steps', 'Reduce JavaScript payload|Split code into smaller chunks|Remove unused code'),
                    'audit_mainthread_work_expl' => tamrank__('audit_mainthread_work_expl', 'The main thread stays busy too long.'),
                    'audit_mainthread_work_steps' => tamrank__('audit_mainthread_work_steps', 'Optimize JavaScript execution|Use Web Workers for heavy tasks|Avoid long tasks (>50ms)'),
                    'audit_third_party_expl' => tamrank__('audit_third_party_expl', 'Third-party scripts slow down the page.'),
                    'audit_third_party_steps' => tamrank__('audit_third_party_steps', 'Audit all third-party scripts|Lazy load non-critical scripts|Self-host where possible'),
                    'audit_lcp_element_expl' => tamrank__('audit_lcp_element_expl', 'The LCP element loads too slowly.'),
                    'audit_lcp_element_steps' => tamrank__('audit_lcp_element_steps', 'Preload the LCP element|Optimize images|Reduce server response time'),
                    'audit_layout_shift_expl' => tamrank__('audit_layout_shift_expl', 'Elements cause unexpected layout shifts.'),
                    'audit_layout_shift_steps' => tamrank__('audit_layout_shift_steps', 'Provide width/height for images|Reserve space for ads/embeds|Avoid injecting content above existing content'),
                    'audit_long_tasks_expl' => tamrank__('audit_long_tasks_expl', 'Tasks longer than 50ms block the main thread.'),
                    'audit_long_tasks_steps' => tamrank__('audit_long_tasks_steps', 'Split long tasks|Use requestIdleCallback|Defer non-critical work'),
                    'audit_unsized_images_expl' => tamrank__('audit_unsized_images_expl', 'Images without explicit sizes cause layout shifts.'),
                    'audit_unsized_images_steps' => tamrank__('audit_unsized_images_steps', 'Add width and height attributes|Use CSS aspect-ratio|Specify dimensions in CSS'),
                    'audit_title_unused_javascript' => tamrank__('audit_title_unused_javascript', 'Reduce unused JavaScript'),
                    'audit_title_unused_css_rules' => tamrank__('audit_title_unused_css_rules', 'Remove unused CSS'),
                    'audit_title_render_blocking_resources' => tamrank__('audit_title_render_blocking_resources', 'Eliminate render-blocking resources'),
                    'audit_title_uses_optimized_images' => tamrank__('audit_title_uses_optimized_images', 'Optimize images'),
                    'audit_title_uses_webp_images' => tamrank__('audit_title_uses_webp_images', 'Serve images in next-gen formats'),
                    'audit_title_offscreen_images' => tamrank__('audit_title_offscreen_images', 'Defer offscreen images'),
                    'audit_title_server_response_time' => tamrank__('audit_title_server_response_time', 'Reduce server response time (TTFB)'),
                    'audit_title_uses_text_compression' => tamrank__('audit_title_uses_text_compression', 'Enable text compression'),
                    'audit_title_uses_long_cache_ttl' => tamrank__('audit_title_uses_long_cache_ttl', 'Serve static assets with an efficient cache policy'),
                    'audit_title_dom_size' => tamrank__('audit_title_dom_size', 'Reduce DOM size'),
                    'audit_title_bootup_time' => tamrank__('audit_title_bootup_time', 'Reduce JavaScript execution time'),
                    'audit_title_mainthread_work_breakdown' => tamrank__('audit_title_mainthread_work_breakdown', 'Minimize main-thread work'),
                    'audit_title_third_party_summary' => tamrank__('audit_title_third_party_summary', 'Reduce impact of third-party code'),
                    'audit_title_largest_contentful_paint_element' => tamrank__('audit_title_largest_contentful_paint_element', 'Largest Contentful Paint element'),
                    'audit_title_layout_shift_elements' => tamrank__('audit_title_layout_shift_elements', 'Avoid large layout shifts'),
                    'audit_title_long_tasks' => tamrank__('audit_title_long_tasks', 'Avoid long main-thread tasks'),
                    'audit_title_unsized_images' => tamrank__('audit_title_unsized_images', 'Set explicit image sizes'),
                    'audit_title_total_byte_weight' => tamrank__('audit_title_total_byte_weight', 'Avoid enormous network payloads'),
                    'audit_title_redirects' => tamrank__('audit_title_redirects', 'Avoid multiple page redirects'),
                    'audit_title_unminified_css' => tamrank__('audit_title_unminified_css', 'Minify CSS'),
                    'audit_title_unminified_javascript' => tamrank__('audit_title_unminified_javascript', 'Minify JavaScript'),
                    'audit_title_user_timings' => tamrank__('audit_title_user_timings', 'User Timing marks and measures'),
                    'audit_title_non_composited_animations' => tamrank__('audit_title_non_composited_animations', 'Avoid non-composited animations'),
                    'audit_desc_unused_javascript' => tamrank__('audit_desc_unused_javascript', 'Defer loading of unused scripts until needed to reduce network activity.'),
                    'audit_desc_unused_css_rules' => tamrank__('audit_desc_unused_css_rules', 'Remove unused CSS so the browser downloads less code.'),
                    'audit_desc_render_blocking_resources' => tamrank__('audit_desc_render_blocking_resources', 'Reduce or defer render-blocking scripts and styles.'),
                    'audit_desc_uses_optimized_images' => tamrank__('audit_desc_uses_optimized_images', 'Serve images that are properly compressed and sized for display.'),
                    'audit_desc_uses_webp_images' => tamrank__('audit_desc_uses_webp_images', 'Serve images in modern formats like WebP or AVIF.'),
                    'audit_desc_offscreen_images' => tamrank__('audit_desc_offscreen_images', 'Lazy-load images that are not visible on initial load.'),
                    'audit_desc_server_response_time' => tamrank__('audit_desc_server_response_time', 'Improve server response times (TTFB) for faster loads.'),
                    'audit_desc_uses_text_compression' => tamrank__('audit_desc_uses_text_compression', 'Compress text-based resources to reduce transfer size.'),
                    'audit_desc_uses_long_cache_ttl' => tamrank__('audit_desc_uses_long_cache_ttl', 'Leverage browser caching for static assets.'),
                    'audit_desc_dom_size' => tamrank__('audit_desc_dom_size', 'Keep the DOM size small to speed up rendering.'),
                    'audit_desc_bootup_time' => tamrank__('audit_desc_bootup_time', 'Reduce the amount of JavaScript executed during page load.'),
                    'audit_desc_mainthread_work_breakdown' => tamrank__('audit_desc_mainthread_work_breakdown', 'Limit main-thread tasks to keep the page responsive.'),
                    'audit_desc_third_party_summary' => tamrank__('audit_desc_third_party_summary', 'Limit the impact of third-party scripts.'),
                    'audit_desc_largest_contentful_paint_element' => tamrank__('audit_desc_largest_contentful_paint_element', 'Speed up loading of the LCP element.'),
                    'audit_desc_layout_shift_elements' => tamrank__('audit_desc_layout_shift_elements', 'Prevent elements from causing layout shifts.'),
                    'audit_desc_long_tasks' => tamrank__('audit_desc_long_tasks', 'Split up long tasks that block the main thread.'),
                    'audit_desc_unsized_images' => tamrank__('audit_desc_unsized_images', 'Add width/height to images to prevent layout shifts.'),
                    'audit_desc_total_byte_weight' => tamrank__('audit_desc_total_byte_weight', 'Large network payloads cost users and slow down loading. Reduce overall transfer size.'),
                    'audit_desc_redirects' => tamrank__('audit_desc_redirects', 'Redirects add extra round trips and delay page load. Minimize or remove them.'),
                    'audit_desc_unminified_css' => tamrank__('audit_desc_unminified_css', 'Minify CSS to reduce its transfer size and parsing time.'),
                    'audit_desc_unminified_javascript' => tamrank__('audit_desc_unminified_javascript', 'Minify JavaScript to reduce its transfer size and execution time.'),
                    'audit_desc_user_timings' => tamrank__('audit_desc_user_timings', 'Instrument your app with User Timing marks and measures for better diagnostics.'),
                    'audit_desc_non_composited_animations' => tamrank__('audit_desc_non_composited_animations', 'Use composited animations (transform/opacity) to avoid layout and paint costs.'),
                    // Confirm dialogs (TamRank.confirm)
                    'cancel' => tamrank__('cancel', 'Cancel'),
                    'confirm' => tamrank__('confirm', 'Confirm'),

                    // Edit redirect dialog
                    'edit' => tamrank__('edit', 'Edit'),
                    'edit_redirect' => tamrank__('edit_redirect', 'Edit Redirect'),
                    'update_redirect' => tamrank__('update_redirect', 'Update'),
                    'redirect_updated' => tamrank__('redirect_updated', 'Redirect updated successfully'),
                    'from_url' => tamrank__('from_url', 'From URL (relative path)'),
                    'to_url' => tamrank__('to_url', 'To URL (full path)'),
                    'redirect_type' => tamrank__('redirect_type', 'Type'),
                    'from_url_placeholder' => tamrank__('from_url_placeholder', '/old-page'),
                    'to_url_placeholder' => tamrank__('to_url_placeholder', 'https://example.com/new-page'),

                    'confirm_default_title' => tamrank__('confirm_default_title', 'Confirm'),
                    'remove' => tamrank__('remove', 'Remove'),
                    'deactivate' => tamrank__('deactivate', 'Deactivate'),
                    'deactivate_license_title' => tamrank__('deactivate_license_title', 'Deactivate license?'),
                    'confirm_deactivate_license' => tamrank__('confirm_deactivate_license', 'You will lose access to PRO features until you reactivate the license.'),

                    // 404 Monitor
                    'clear_log_title' => tamrank__('clear_log_title', 'Clear 404 log?'),
                    'confirm_clear_log' => tamrank__('confirm_clear_log', 'All 404 errors will be permanently deleted. This action cannot be undone.'),
                    'ignore_urls_title' => tamrank__('ignore_urls_title', 'Ignore URL(s)?'),
                    'confirm_ignore_404' => tamrank__('confirm_ignore_404', 'These URL(s) will be removed from the log and not registered in the future.'),
                    'ignore' => tamrank__('ignore', 'Ignore'),
                    'ignoring' => tamrank__('ignoring', 'Working...'),
                    'urls_ignored' => tamrank__('urls_ignored', 'URL(s) ignored'),
                    'urls_added_to_blocklist' => tamrank__('urls_added_to_blocklist', 'URL(s) added to blocklist'),
                    'clearing' => tamrank__('clearing', 'Clearing...'),
                    'clear_log' => tamrank__('clear_log', 'Clear Log'),
                    'log_cleared' => tamrank__('log_cleared', '404 log has been cleared'),
                    'redirect_created' => tamrank__('redirect_created', 'Redirect created'),
                    'redirect_error' => tamrank__('redirect_error', 'Error creating redirect'),
                    'selected' => tamrank__('selected', 'selected'),
                    'creating' => tamrank__('creating', 'Creating...'),
                    'saving' => tamrank__('saving', 'Saving...'),
                    'session_expired_message' => tamrank__('session_expired_message', 'Session expired. Refresh the page and try again.'),
                    'optimizations_saved' => tamrank__('optimizations_saved', 'Optimizations saved'),
                    'draft_saved' => tamrank__('draft_saved', 'Draft saved'),
                    'changes_processed' => tamrank__('changes_processed', 'Your changes have been processed'),
                    'hit_count' => tamrank__('hit_count', 'Hits: '),
                    'redirects_create_title' => tamrank__('redirects_create_title', ' redirects to create'),
                    'create_redirect' => tamrank__('create_redirect', 'Create redirect'),
                    'redirects_created' => tamrank__('redirects_created', ' redirects created'),
                    'failed_suffix' => tamrank__('failed_suffix', ' failed'),
                    'redirect_created' => tamrank__('redirect_created', 'Redirects created'),
                    'exporting' => tamrank__('exporting', 'Exporting...'),
                    'export_complete' => tamrank__('export_complete', 'Export complete'),
                    'csv_downloaded' => tamrank__('csv_downloaded', 'CSV file downloaded'),
                    // PageSpeed CSV export headers (Wave 3)
                    'csv_col_page'      => tamrank__('csv_col_page', 'Page'),
                    'csv_col_url'       => tamrank__('csv_col_url', 'URL'),
                    'csv_col_average'   => tamrank__('csv_col_average', 'Average'),
                    'csv_col_mobile'    => tamrank__('csv_col_mobile', 'Mobile'),
                    'csv_col_desktop'   => tamrank__('csv_col_desktop', 'Desktop'),
                    'csv_col_tested_on' => tamrank__('csv_col_tested_on', 'Tested on'),
                    'confirm_deactivate_license' => tamrank__('confirm_deactivate_license', 'Are you sure you want to deactivate the license?'),
                    'busy' => tamrank__('busy', 'Processing...'),
                    'indexing_requested' => tamrank__('indexing_requested', 'Indexing requested'),
                    'requested_checkmark' => tamrank__('requested_checkmark', 'Requested ✓'),
                    'request_indexing' => tamrank__('request_indexing', 'Request indexing'),
                    'unknown_error' => tamrank__('unknown_error', 'Unknown error'),
                    'indexing_failed' => tamrank__('indexing_failed', 'Indexing failed'),
                    'reconnect_required' => tamrank__('reconnect_required', 'Reconnection required'),
                    'auto_indexing_title' => tamrank__('auto_indexing_title', 'Indexing automatically requested'),
                    'auto_indexing_message' => tamrank__('auto_indexing_message', 'Google has been notified of your update'),
                    'generating' => tamrank__('generating', 'Generating...'),
                    'generating_progress' => tamrank__('generating_progress', 'Generating... '),
                    'error_prefix' => tamrank__('error_prefix', 'Error: '),
                    'error_status_check' => tamrank__('error_status_check', 'Error checking status.'),
                    'saved' => tamrank__('saved', 'Saved!'),
                    'save_and_use' => tamrank__('save_and_use', 'Save and use'),
                    'save_failed' => tamrank__('save_failed', 'Save failed'),
                    'confirm_clear_log' => tamrank__('confirm_clear_log', 'Are you sure you want to clear the 404 log?'),
                    'log_cleared_title' => tamrank__('log_cleared_title', 'Log cleared'),
                    'log_cleared_message' => tamrank__('log_cleared_message', '404 log has been cleared successfully'),
                    'error_occurred' => tamrank__('error_occurred', 'Error'),
                    'error_generic_message' => tamrank__('error_generic_message', 'An error occurred.'),
                    // Redirect Manager — toast titles
                    'success_title'            => tamrank__('success_title', 'Done'),
                    'title_redirect_toggle'    => tamrank__('title_redirect_toggle', 'Status not updated'),
                    'title_redirect_delete'    => tamrank__('title_redirect_delete', 'Redirect not deleted'),
                    'title_chain_fix'          => tamrank__('title_chain_fix', 'Optimisation failed'),
                    'title_scan'               => tamrank__('title_scan', 'Scan failed'),
                    'title_export'             => tamrank__('title_export', 'Export failed'),
                    'title_export_success'     => tamrank__('title_export_success', 'Export'),
                    'title_csv_select'         => tamrank__('title_csv_select', 'No file selected'),
                    'title_suggestions'        => tamrank__('title_suggestions', 'Suggestions unavailable'),
                    'title_suggestion_action'  => tamrank__('title_suggestion_action', 'Action failed'),
                    // Redirect Manager — toast bodies
                    'error_redirect_toggle'    => tamrank__('error_redirect_toggle', 'The redirect status could not be updated. Try again.'),
                    'error_redirect_delete'    => tamrank__('error_redirect_delete', 'The redirect could not be deleted. Try again.'),
                    'error_redirect_chain'     => tamrank__('error_redirect_chain', 'The redirect chain could not be resolved. Reload the page and try again.'),
                    'error_redirect_scan'      => tamrank__('error_redirect_scan', 'The scan could not be completed. Try again in a moment.'),
                    'error_redirect_export'    => tamrank__('error_redirect_export', 'Export failed. Try again or check your server.'),
                    'error_csv_select'         => tamrank__('error_csv_select', 'Select a CSV file first.'),
                    'error_suggestions_load'   => tamrank__('error_suggestions_load', 'Redirect suggestions could not be loaded. Try again in a moment.'),
                    'error_suggestion_accept'  => tamrank__('error_suggestion_accept', 'The suggestion could not be accepted. Try again.'),
                    'error_suggestion_reject'  => tamrank__('error_suggestion_reject', 'The suggestion could not be rejected. Try again.'),
                )
            ));

            // GSC Dashboard scripts (only on dashboard and settings pages)
            if (strpos($hook, 'tam-rank') !== false) {
                $this->enqueue_gsc_scripts();
            }
        }
    }

    /**
     * Enqueue GSC Dashboard scripts and styles
     */
    private function enqueue_gsc_scripts()
    {
        // Only load if GSC is connected or on settings/dashboard page
        $is_connected = class_exists('TamRank_GSC_Connector') && TamRank_GSC_Connector::is_connected();
        $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request inspection (admin page / OAuth callback / menu detection); no state-changing action to protect.
        $is_gsc_page = in_array($current_page, array('tam-rank-settings', 'tam-rank-dashboard', 'tamrank'), true);

        if (!$is_connected && !$is_gsc_page) {
            return;
        }

        // ApexCharts library — bundled locally per wp.org policy on external assets.
        // Source: https://github.com/apexcharts/apexcharts.js v4.7.0, MIT licensed.
        // Pinned to 4.x: v5.0.0+ dropped MIT for a dual/OEM license (not GPL-compatible
        // for redistribution in a wp.org plugin); v4.7.0 is the last MIT release.
        wp_enqueue_script(
            'tamrank-apexcharts',
            TAM_RANK_PLUGIN_URL . 'assets/js/vendor/apexcharts.min.js',
            array(),
            '4.7.0',
            true
        );

        // GSC Dashboard script
        wp_enqueue_script(
            'tam-rank-gsc-dashboard',
            TAM_RANK_PLUGIN_URL . 'assets/js/gsc-dashboard.js',
            array('jquery', 'tamrank-apexcharts'),
            TAM_RANK_VERSION,
            true
        );

        // Localize script for AJAX
        wp_localize_script('tam-rank-gsc-dashboard', 'tamrank_gsc', array(
            'ajax_url' => admin_url('admin-ajax.php'),
            'nonce' => wp_create_nonce('tamrank_gsc_nonce'),
            'is_connected' => $is_connected,
            'i18n' => array(
                'loading' => tamrank__('gsc_loading', 'Loading data...'),
                'error' => tamrank__('gsc_error', 'Could not load data'),
                'no_data' => tamrank__('gsc_no_data', 'No data available'),
            ),
            'strings' => array(
                'error_fetching_data' => tamrank__('error_fetching_data', 'Error fetching data'),
                'connection_error' => tamrank__('connection_error', 'Connection error'),
            ),
        ));
    }

    /**
     * Add Tam Rank menu to admin bar
     */
    public function add_admin_bar_menu($wp_admin_bar)
    {
        if (!current_user_can('manage_options')) {
            return;
        }

        // Main menu item — icon rendered via .ab-icon span styled by admin_bar_styles()
        $wp_admin_bar->add_node(array(
            'id' => 'tam-rank',
            'title' => '<span class="ab-icon tam-rank-ab-icon" aria-hidden="true"></span><span class="ab-label">Tam Rank</span>',
            'href' => admin_url('admin.php?page=tam-rank-dashboard'),
            'meta' => array(
                'title' => 'Tam Rank SEO'
            )
        ));

        // Freemium model: All submenu items visible, PRO features marked
        $is_pro = tam_rank_license_active();

        // Dashboard (FREE)
        $wp_admin_bar->add_node(array(
            'id' => 'tam-rank-dashboard',
            'parent' => 'tam-rank',
            'title' => __('Dashboard', 'tam-rank'),
            'href' => admin_url('admin.php?page=tam-rank-dashboard')
        ));

        // Instellingen (FREE)
        $wp_admin_bar->add_node(array(
            'id' => 'tam-rank-settings',
            'parent' => 'tam-rank',
            'title' => __('Settings', 'tam-rank'),
            'href' => admin_url('admin.php?page=tam-rank-settings')
        ));

        // Redirects (FREE)
        $wp_admin_bar->add_node(array(
            'id' => 'tam-rank-redirects',
            'parent' => 'tam-rank',
            'title' => __('Redirects', 'tam-rank'),
            'href' => admin_url('admin.php?page=tam-rank-redirects')
        ));

        // SEO Audit (Freemium: basis gratis)
        $wp_admin_bar->add_node(array(
            'id' => 'tam-rank-audit',
            'parent' => 'tam-rank',
            'title' => __('SEO Audit', 'tam-rank'),
            'href' => admin_url('admin.php?page=tam-rank-audit')
        ));
    }

    /**
     * Admin bar styles for Tam Rank icon
     */
    public function admin_bar_styles()
    {
        if (!is_admin_bar_showing()) {
            return;
        }

        $css = <<<'CSS'
#wpadminbar #wp-admin-bar-tam-rank > .ab-item {
    display: flex !important;
    align-items: center !important;
    gap: 6px;
}
#wpadminbar #wp-admin-bar-tam-rank .tam-rank-ab-icon {
    display: inline-block;
    width: 20px;
    height: 20px;
    background-color: #a7aaad;
    -webkit-mask-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB4PSIxMCIgeT0iMTAiIHdpZHRoPSIyMCIgaGVpZ2h0PSI1IiByeD0iMiIgZmlsbD0iYmxhY2siLz48cmVjdCB4PSIxNSIgeT0iMTAiIHdpZHRoPSI1IiBoZWlnaHQ9IjIwIiByeD0iMiIgZmlsbD0iYmxhY2siLz48cmVjdCB4PSIyMiIgeT0iMTgiIHdpZHRoPSI1IiBoZWlnaHQ9IjEyIiByeD0iMiIgZmlsbD0iYmxhY2siIG9wYWNpdHk9IjAuNSIvPjwvc3ZnPg==");
    mask-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB4PSIxMCIgeT0iMTAiIHdpZHRoPSIyMCIgaGVpZ2h0PSI1IiByeD0iMiIgZmlsbD0iYmxhY2siLz48cmVjdCB4PSIxNSIgeT0iMTAiIHdpZHRoPSI1IiBoZWlnaHQ9IjIwIiByeD0iMiIgZmlsbD0iYmxhY2siLz48cmVjdCB4PSIyMiIgeT0iMTgiIHdpZHRoPSI1IiBoZWlnaHQ9IjEyIiByeD0iMiIgZmlsbD0iYmxhY2siIG9wYWNpdHk9IjAuNSIvPjwvc3ZnPg==");
    -webkit-mask-size: contain;
    mask-size: contain;
    -webkit-mask-position: center;
    mask-position: center;
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    transition: background-color .15s ease;
}
#wpadminbar #wp-admin-bar-tam-rank:hover .tam-rank-ab-icon,
#wpadminbar #wp-admin-bar-tam-rank.hover .tam-rank-ab-icon {
    background-color: #fff;
}
#wpadminbar #wp-admin-bar-tam-rank:hover>.ab-item {
    background: rgba(37, 172, 117, 0.2) !important;
}
CSS;

        wp_register_style('tam-rank-admin-bar', false, array(), TAM_RANK_VERSION);
        wp_enqueue_style('tam-rank-admin-bar');
        wp_add_inline_style('tam-rank-admin-bar', $css);
    }

    /**
     * Feed TamRank's robots directives into WordPress' own robots tag.
     *
     * WordPress prints exactly one <meta name="robots"> itself through
     * wp_robots(), and core adds max-image-preview:large there by default. So
     * echoing a second tag of our own produced two tags on every page, and once
     * the advanced directives landed those two could contradict each other:
     * core saying max-image-preview:large next to a page set to standard.
     *
     * Going through the filter keeps it at one tag, lets our values win on the
     * keys we set, and leaves everything else on the page intact. This is the
     * same route other SEO plugins take since WP 5.7; the plugin requires 6.0,
     * so the filter is always there.
     *
     * @param array $robots Directive => true (bare) or => string (key:value).
     * @return array
     */
    public function filter_robots($robots)
    {
        $noindex  = false;
        $nofollow = false;
        // Advanced directives (noarchive, nosnippet, max-snippet:n, ...) are
        // read separately from noindex/nofollow so those two keep their existing
        // meta keys and behaviour — no migration.
        $advanced = array();

        if (is_singular()) {
            $post_id  = get_queried_object_id();
            $noindex  = (bool) get_post_meta($post_id, '_tam_rank_noindex', true);
            $nofollow = (bool) get_post_meta($post_id, '_tam_rank_nofollow', true);
            $advanced = tamrank_build_robots_directives($post_id, 'post');
        } elseif (is_category() || is_tag() || is_tax()) {
            $term = get_queried_object();
            if ($term && !is_wp_error($term)) {
                $noindex  = (bool) get_term_meta($term->term_id, '_tam_rank_noindex', true);
                $nofollow = (bool) get_term_meta($term->term_id, '_tam_rank_nofollow', true);
                $advanced = tamrank_build_robots_directives($term->term_id, 'term');
            }
        }

        $has_own = $noindex || $nofollow || !empty($advanced);

        if ($noindex) {
            unset($robots['index']);
            $robots['noindex'] = true;
        }

        if ($nofollow) {
            unset($robots['follow']);
            $robots['nofollow'] = true;
        }

        // tamrank_build_robots_directives() hands back rendered strings such as
        // 'noarchive' and 'max-snippet:120'; split those back into the
        // key => value shape wp_robots() expects.
        foreach ($advanced as $directive) {
            if (strpos($directive, ':') !== false) {
                list($key, $value) = explode(':', $directive, 2);
                $robots[$key] = $value;
            } else {
                $robots[$directive] = true;
            }
        }

        // Contextual noindex for system pages: it only applies when the page
        // itself sets nothing at all — a page with only "noarchive" counts as
        // set and must not be silently deindexed.
        //
        // The archive part (search, author, date) moved into
        // tam_rank_context_is_noindex_archive() in 1.3.8 so it can be switched
        // per archive type and so the X-Robots-Tag header below reads the same
        // answer instead of deciding for itself. 404 and paged stay hard rules:
        // neither has a setting and neither should ever be indexed.
        if (!$has_own && (tam_rank_context_is_noindex_archive() || is_404() || is_paged())) {
            unset($robots['index']);
            $robots['noindex'] = true;
            $robots['follow']  = true;
        }

        return $robots;
    }

    /**
     * De canonical die TamRank voor de huidige context heeft staan.
     *
     * Eén bron voor twee lezers: output_meta_tags() print 'm, en
     * tam_rank_maybe_remove_core_canonical() beslist er op prioriteit 9 mee of
     * core's eigen rel_canonical mag blijven staan. Die twee mogen nooit uit
     * elkaar lopen, anders krijg je weer twee tags of juist geen.
     *
     * Geeft de opgeslagen waarde ongewijzigd terug (get_post_meta/get_term_meta
     * met single=true, dus '' als er niets staat) - zodat de print-kant zich
     * precies gedraagt zoals voorheen.
     *
     * @return mixed De opgeslagen canonical, of '' als er niets is gezet.
     */
    private function tam_rank_get_canonical_for_context()
    {
        if (is_singular()) {
            return get_post_meta(get_queried_object_id(), '_tam_rank_canonical', true);
        }

        if (is_category() || is_tag() || is_tax()) {
            $term = get_queried_object();
            if ($term && !is_wp_error($term)) {
                return get_term_meta($term->term_id, '_tam_rank_canonical', true);
            }
        }

        return '';
    }

    /**
     * Haal core's rel_canonical weg zodra wij zelf een canonical printen.
     *
     * Hangt op wp_head prioriteit 9, dus vóór core's eigen rel_canonical op 10.
     * Stond de remove_action() in output_meta_tags() (prioriteit 20), dan was
     * core's tag al geprint en had de pagina er twee - met verschillende URL's.
     * Zonder eigen canonical doen we niets en blijft core's tag staan.
     */
    public function tam_rank_maybe_remove_core_canonical()
    {
        // Zelfde !empty()-toets als de print-kant hieronder: wat wij niet
        // printen, mag core ook niet kwijtraken.
        $canonical = $this->tam_rank_get_canonical_for_context();

        if (!empty($canonical)) {
            remove_action('wp_head', 'rel_canonical');
        }
    }

    public function output_meta_tags()
    {
        // NOTE: geen <title> hier, titel wordt via WP filters gezet.
        $desc = '';
        $canonical = $this->tam_rank_get_canonical_for_context();

        if (is_singular()) {
            $post_id = get_queried_object_id();
            $desc = get_post_meta($post_id, '_tam_rank_meta_description', true);

            // Vervang dynamische variabelen in beschrijving
            if (!empty($desc) && function_exists('tamrank_replace_dynamic_variables')) {
                $desc = tamrank_replace_dynamic_variables($desc, $post_id);
            }
        } elseif (is_category() || is_tag() || is_tax()) {
            $term = get_queried_object();
            if ($term && !is_wp_error($term)) {
                $desc = get_term_meta($term->term_id, '_tam_rank_meta_description', true);
            }
        }

        if (empty($desc)) {
            $desc = get_option('tam_rank_default_description', '');
        }

        if (!empty($desc)) {
            echo '<meta name="description" content="' . esc_attr(wp_strip_all_tags($desc)) . '">' . "\n";
        }

        // Custom canonical URL (overschrijft WP default)
        // Core's eigen rel_canonical is hier al weggehaald door
        // tam_rank_maybe_remove_core_canonical() op prioriteit 9.
        if (!empty($canonical)) {
            echo '<link rel="canonical" href="' . esc_url($canonical) . '" />' . "\n";
        }

        // De robots-tag wordt NIET hier geprint maar via filter_robots() aan
        // WordPress' eigen wp_robots() gevoerd. Zie die methode voor het waarom.

        // OG/Twitter — social overrides take priority
        $og_title = '';
        $og_desc = '';
        $og_image = '';

        if (is_singular()) {
            $post_id = get_queried_object_id();
            $og_title = get_post_meta($post_id, '_tam_rank_social_title', true);
            $og_desc = get_post_meta($post_id, '_tam_rank_social_description', true);
            $og_image = get_post_meta($post_id, '_tam_rank_social_image', true);

            // Zelfde variabelen-slag als de meta-titel en de meta-omschrijving
            // hierboven: de migrators zetten %sitename% en vrienden ook in de
            // social-velden, en zonder deze stap staan die rauw in de og-tags.
            if (function_exists('tamrank_replace_dynamic_variables')) {
                if (!empty($og_title)) {
                    $og_title = tamrank_replace_dynamic_variables($og_title, $post_id);
                }
                if (!empty($og_desc)) {
                    $og_desc = tamrank_replace_dynamic_variables($og_desc, $post_id);
                }
            }

            if (empty($og_image) && has_post_thumbnail($post_id)) {
                $og_image = get_the_post_thumbnail_url($post_id, 'large');
            }
        } elseif (is_category() || is_tag() || is_tax()) {
            $term = get_queried_object();
            if ($term && !is_wp_error($term)) {
                $og_title = get_term_meta($term->term_id, '_tam_rank_social_title', true);
                $og_desc = get_term_meta($term->term_id, '_tam_rank_social_description', true);
                $og_image = get_term_meta($term->term_id, '_tam_rank_social_image', true);
            }
        }

        // Site-wide fallback image, last rung of the chain: a per-post override
        // and a featured image both win, and an unset option leaves $og_image
        // empty so no og:image and no twitter:image tag is printed at all -
        // byte-for-byte the behaviour from before this setting existed. Applied
        // on every front-end view, not just singular ones, because that is what
        // a site-wide fallback means and what the source plugins we migrate it
        // from (Yoast og_default_image, Rank Math open_graph_image, AIOSEO
        // defaultImagePosts) do with theirs.
        if (empty($og_image)) {
            $og_image = tam_rank_get_default_social_image();
        }

        // Fallback to SEO title/description
        if (empty($og_title)) {
            $og_title = $this->tam_rank_get_custom_meta_title_for_context();
        }
        if (empty($og_desc)) {
            $og_desc = $desc;
        }

        // One source for the whole social set: which tags exist, in which order,
        // and their social-specific fallbacks all live in
        // tam_rank_get_social_meta_tags(). This method only resolves what is
        // context-dependent (post, term or archive, plus the %variable% pass
        // above) and hands that over. Printing a tag that is not in that array,
        // or resolving a value a second time here, is how the set drifted apart
        // before 1.3.8.2.
        $social_tags = tam_rank_get_social_meta_tags(
            is_singular() ? get_queried_object_id() : 0,
            array(
                'title'       => (string) $og_title,
                'description' => (string) $og_desc,
                'image'       => (string) $og_image,
                'url'         => !empty($canonical) ? (string) $canonical : (is_singular() ? (string) get_permalink(get_queried_object_id()) : ''),
            )
        );

        foreach ($social_tags as $name => $value) {
            if ('' === $value || null === $value) {
                continue;
            }

            // og:* and article:* are property=, twitter:* is name=. Same split
            // Yoast and Rank Math make, and what the validators expect.
            $attribute = (0 === strpos($name, 'twitter:')) ? 'name' : 'property';
            $content   = (false !== strpos($name, 'image') && false === strpos($name, ':width') && false === strpos($name, ':height') && false === strpos($name, ':type'))
                ? esc_url($value)
                : esc_attr($value);

            echo '<meta ' . esc_attr($attribute) . '="' . esc_attr($name) . '" content="' . $content . '">' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $content is passed through esc_url()/esc_attr() directly above.
        }
    }

    public function custom_robots_txt($output, $public)
    {
        // Migration capture bypass (see TamRank_Robots_Capture). The wizard fetches
        // /robots.txt over loopback HTTP while the old SEO plugin is still active, to
        // record what that plugin actually serves. Our own filter has to step aside in
        // that one request, and remove_filter() cannot reach it because the capture runs
        // in a different process. No secret is needed: robots.txt is public and this
        // parameter only exposes what WordPress plus the other plugins would serve
        // anyway. Note the query string also bypasses most page caches; a cache in
        // ignore-query-string mode could in theory store the bypassed body under the
        // plain /robots.txt key.
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public, read-only bypass
        if (isset($_GET['tamrank_capture'])) {
            return $output;
        }

        // Non-public site: leave WordPress' own output alone, the same way Rank Math
        // does. Our seeded default advertises Sitemap: <home>/sitemap.xml, and a site
        // that explicitly asks not to be found should not advertise its sitemap. This
        // is not an indexing guard - non-public status runs through the wp_robots meta
        // tag and is unaffected either way.
        if (0 === absint($public)) {
            return $output;
        }

        $custom_robots = get_option('tam_rank_robots_txt', '');

        if (empty($custom_robots)) {
            return $output;
        }

        return tam_rank_strip_own_sitemap_directives($custom_robots);
    }

    public function monitor_404()
    {
        if (is_404()) {
            $url = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';

            // Filter noise: bots, scanners, probes, non-content URLs.
            if ($this->should_ignore_404_url($url)) {
                return;
            }

            $this->log_404($url);
        }
    }

    /**
     * Bepaal of een 404 URL genegeerd moet worden
     *
     * Filtert technische/systeem requests die geen echte 404's zijn:
     * - Favicon requests (apple-touch-icon, favicon.ico)
     * - Source map bestanden (.map)
     * - WordPress core bestanden
     * - Bot/crawler noise
     *
     * @since 1.4.0
     * @param string $url De 404 URL
     * @return bool True als de URL genegeerd moet worden
     */
    private function should_ignore_404_url($url)
    {
        $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
        $url_lower = strtolower($url);
        $path = wp_parse_url($url_lower, PHP_URL_PATH) ?: $url_lower;

        // ── 0. User-managed blocklist (Ignore knop op 404 Monitor) ──
        $ignored_urls = get_option('tam_rank_404_ignored_urls', array());
        if (is_array($ignored_urls) && !empty($ignored_urls)) {
            // Case-sensitive exact match — URLs zijn opgeslagen zoals oorspronkelijk gelogd
            if (in_array($url, $ignored_urls, true)) {
                return true;
            }
        }

        // Load filter lists from JSON to keep sensitive strings out of PHP source
        static $filters = null;
        if ($filters === null) {
            $json_path = TAM_RANK_PLUGIN_PATH . 'assets/data/404-noise-filters.json';
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a bundled local JSON asset, not a remote resource.
            $filters = file_exists($json_path) ? json_decode(file_get_contents($json_path), true) : array();
        }

        // ── 1. Exact substring matches (fast) ──────────────────────

        $ignore_patterns = isset($filters['ignore_patterns']) ? $filters['ignore_patterns'] : array();
        $ignore_patterns = apply_filters('tam_rank_404_ignore_patterns', $ignore_patterns);

        foreach ($ignore_patterns as $pattern) {
            if (strpos($url_lower, $pattern) !== false) {
                return true;
            }
        }

        // ── 2. File extension filter ───────────────────────────────

        $ignore_extensions = isset($filters['ignore_extensions']) ? $filters['ignore_extensions'] : array();
        $ignore_extensions = apply_filters('tam_rank_404_ignore_extensions', $ignore_extensions);

        foreach ($ignore_extensions as $ext) {
            if (substr($path, -strlen($ext)) === $ext) {
                return true;
            }
        }

        // ── 3. Regex patterns for scanner noise ────────────────────

        $noise_regexes = isset($filters['noise_regexes']) ? $filters['noise_regexes'] : array();
        $noise_regexes = apply_filters('tam_rank_404_noise_regexes', $noise_regexes);

        foreach ($noise_regexes as $regex) {
            if (preg_match($regex, $path)) {
                return true;
            }
        }

        // ── 4. User-agent bot filter ───────────────────────────────

        $ua = !empty($user_agent) ? strtolower($user_agent) : '';
        if (!empty($ua)) {
            $scanner_agents = isset($filters['scanner_agents']) ? $filters['scanner_agents'] : array();
            $scanner_agents = apply_filters('tam_rank_404_scanner_agents', $scanner_agents);

            foreach ($scanner_agents as $agent) {
                if (strpos($ua, $agent) !== false) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Log een 404 error naar de database
     *
     * Slaat de 404 URL op voor analyse van kapotte links.
     *
     * @since 1.0.0
     *
     * @param string $url De 404 URL (REQUEST_URI)
     */
    private function log_404($url)
    {
        global $wpdb, $tam_rank_redirect_suggestions;

        $table_name = $wpdb->prefix . 'tam_rank_404_log';
        $sanitized_url = sanitize_text_field($url);

        // The free 404 log keeps only the URL + timestamp (matching the readme).
        // No visitor PII (IP / user-agent) is stored; the bot noise filter reads
        // the live request user-agent and does not persist it.
        $insert_data = array(
            'url' => $sanitized_url,
            'timestamp' => current_time('mysql'),
        );

        // Data types voor de kolommen
        $data_formats = array('%s', '%s');

        $wpdb->insert($table_name, $insert_data, $data_formats); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- direct write on a custom plugin table (tam_rank_404_log); $wpdb->insert with format placeholders, caching not applicable

        // Genereer automatisch redirect suggesties voor nieuwe 404 URLs
        if ($tam_rank_redirect_suggestions) {
            $this->maybe_generate_redirect_suggestion($sanitized_url);
        }

        /**
         * Fires right after a 404 hit is logged.
         *
         * The free build records only the URL and timestamp. Add-ons can
         * hook this to enrich or post-process the logged row.
         *
         * @param int    $log_id        The inserted row id.
         * @param string $sanitized_url The logged 404 URL.
         */
        do_action('tam_rank_404_logged', (int) $wpdb->insert_id, $sanitized_url);
    }

    /**
     * Genereer redirect suggestie voor een 404 URL (alleen als er nog geen bestaat)
     */
    private function maybe_generate_redirect_suggestion($url)
    {
        global $wpdb, $tam_rank_redirect_suggestions;

        // Check of er al een suggestie of redirect bestaat voor deze URL
        $suggestions_table = $wpdb->prefix . 'tam_rank_redirect_suggestions';
        $redirects_table = $wpdb->prefix . 'tam_rank_redirects';

        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on a custom plugin table (tam_rank_redirect_suggestions); only the table identifier $suggestions_table is interpolated, user value uses prepare() %s placeholder; request-scoped read, caching not applicable
        $existing_suggestion = $wpdb->get_var($wpdb->prepare(
            "SELECT id FROM $suggestions_table WHERE source_url = %s LIMIT 1",
            $url
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter

        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on a custom plugin table (tam_rank_redirects); only the table identifier $redirects_table is interpolated, user value uses prepare() %s placeholder; request-scoped read, caching not applicable
        $existing_redirect = $wpdb->get_var($wpdb->prepare(
            "SELECT id FROM $redirects_table WHERE source_url = %s LIMIT 1",
            $url
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter

        if ($existing_suggestion || $existing_redirect) {
            return; // Al een suggestie of redirect aanwezig
        }

        // Genereer suggesties
        $suggestions = $tam_rank_redirect_suggestions->generate_suggestions($url);

        // Sla de beste suggestie op (als er een is)
        foreach ($suggestions as $suggestion) {
            $tam_rank_redirect_suggestions->save_suggestion(
                $url,
                $suggestion['post_id'],
                $suggestion['url'],
                $suggestion['score']
            );
        }
    }

    public function plugin_action_links($links)
    {
        $settings_link = '<a href="' . esc_url(admin_url('admin.php?page=tam-rank-settings')) . '">' . __('Settings', 'tam-rank') . '</a>';
        array_unshift($links, $settings_link);
        return $links;
    }

    /**
     * Add a "Data settings" link to the plugin's row meta on the Plugins screen.
     *
     * Deep-links to the Privacy & Data card so users can review what happens to
     * their data on uninstall (and toggle the wipe-on-uninstall option) right
     * where they would delete the plugin.
     *
     * @since 1.2.9
     * @param array  $meta Existing row-meta links.
     * @param string $file Plugin file the row meta is for.
     * @return array
     */
    public function plugin_row_meta($meta, $file)
    {
        if ($file === TAM_RANK_PLUGIN_BASENAME) {
            $meta[] = '<a href="' . esc_url(admin_url('admin.php?page=tam-rank-settings#tr-privacy-data')) . '">'
                . esc_html(tamrank__('manage_data_settings', 'Data settings')) . '</a>';
        }
        return $meta;
    }

    public function activate()
    {
        $this->create_database_tables();
        $this->set_default_options();
        flush_rewrite_rules();
    }

    public function deactivate()
    {
        // Clear recurring cron events so they stop firing while the plugin is
        // deactivated (recurring schedules never self-expire). Single events
        // self-expire and need no action; uninstall.php clears these too.
        wp_clear_scheduled_hook('tamrank_weekly_learning');
        wp_clear_scheduled_hook('tamrank_daily_cleanup');
        wp_clear_scheduled_hook('tamrank_link_graph_scan');

        flush_rewrite_rules();
    }

    private function create_database_tables()
    {
        global $wpdb;

        $charset_collate = $wpdb->get_charset_collate();

        // 404 log table
        $table_name = $wpdb->prefix . 'tam_rank_404_log';
        $sql = "CREATE TABLE $table_name (
            id mediumint(9) NOT NULL AUTO_INCREMENT,
            url varchar(255) NOT NULL,
            timestamp datetime DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (id),
            KEY url (url),
            KEY timestamp (timestamp)
        ) $charset_collate;";

        // Redirects table
        $redirects_table = $wpdb->prefix . 'tam_rank_redirects';
        $sql2 = "CREATE TABLE $redirects_table (
            id mediumint(9) NOT NULL AUTO_INCREMENT,
            source_url varchar(255) NOT NULL,
            target_url varchar(255) NOT NULL,
            redirect_type varchar(10) DEFAULT '301',
            match_type varchar(10) NOT NULL DEFAULT 'exact',
            hits int(11) DEFAULT 0,
            active tinyint(1) DEFAULT 1,
            auto_generated tinyint(1) DEFAULT 0,
            created_at datetime DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (id),
            UNIQUE KEY source_url (source_url)
        ) $charset_collate;";

        // Redirect suggestions table (v2 met confidence, decision tracking)
        $suggestions_table = $wpdb->prefix . 'tam_rank_redirect_suggestions';
        $sql3 = "CREATE TABLE $suggestions_table (
            id mediumint(9) NOT NULL AUTO_INCREMENT,
            source_url varchar(255) NOT NULL,
            target_id bigint(20) DEFAULT 0,
            target_url varchar(255) NOT NULL,
            score int(3) DEFAULT 0,
            confidence_score float DEFAULT 0,
            status varchar(20) DEFAULT 'pending',
            match_reasons text,
            created_at datetime DEFAULT CURRENT_TIMESTAMP,
            decision_at datetime DEFAULT NULL,
            PRIMARY KEY (id),
            KEY source_url (source_url),
            KEY status (status),
            KEY score (score),
            KEY confidence_score (confidence_score)
        ) $charset_collate;";

        // Redirect statistieken tabel (voor learning)
        $stats_table = $wpdb->prefix . 'tam_rank_redirect_stats';
        $sql5 = "CREATE TABLE $stats_table (
            id mediumint(9) NOT NULL AUTO_INCREMENT,
            score_range varchar(20) NOT NULL,
            match_type varchar(50) NOT NULL,
            suggested_count int(11) DEFAULT 0,
            accepted_count int(11) DEFAULT 0,
            dismissed_count int(11) DEFAULT 0,
            last_updated datetime DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (id),
            UNIQUE KEY range_type (score_range, match_type)
        ) $charset_collate;";

        // Topical Authority Maps + Change Log are PRO-only features. Their
        // tables are created by the PRO add-on's own activation routine, so the
        // free plugin no longer provisions them here.

        require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
        dbDelta($sql);  // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- table schema creation on plugin activation via dbDelta()
        dbDelta($sql2); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- table schema creation on plugin activation via dbDelta()
        dbDelta($sql3); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- table schema creation on plugin activation via dbDelta()
        dbDelta($sql5); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- table schema creation on plugin activation via dbDelta()

        // Migraties voor bestaande tabellen
        $this->run_table_migrations($redirects_table, $suggestions_table);
    }

    /**
     * Run database migrations for existing tables
     */
    private function run_table_migrations($redirects_table, $suggestions_table)
    {
        global $wpdb;

        // Redirects table migrations
        $redirect_columns = array(
            'auto_generated' => "ALTER TABLE $redirects_table ADD COLUMN auto_generated tinyint(1) DEFAULT 0 AFTER active",
            'hit_count' => "ALTER TABLE $redirects_table ADD COLUMN hit_count int(11) DEFAULT 0",
            'first_hit_at' => "ALTER TABLE $redirects_table ADD COLUMN first_hit_at datetime DEFAULT NULL",
            'last_hit_at' => "ALTER TABLE $redirects_table ADD COLUMN last_hit_at datetime DEFAULT NULL",
            'created_by' => "ALTER TABLE $redirects_table ADD COLUMN created_by varchar(20) DEFAULT 'manual'",
            'match_type' => "ALTER TABLE $redirects_table ADD COLUMN match_type varchar(10) NOT NULL DEFAULT 'exact' AFTER redirect_type",
        );

        foreach ($redirect_columns as $column => $sql) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only table/column identifiers are interpolated ($column is a hardcoded array key, $redirects_table is $wpdb->prefix + constant); schema migration on activation, caching not applicable
            $exists = $wpdb->get_results("SHOW COLUMNS FROM $redirects_table LIKE '$column'");
            if (empty($exists)) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is a hardcoded ALTER TABLE statement built from array constants; table schema migration on activation, caching not applicable
                $wpdb->query($sql);
            }
        }

        // Suggestions table migrations
        $suggestion_columns = array(
            'confidence_score' => "ALTER TABLE $suggestions_table ADD COLUMN confidence_score float DEFAULT 0 AFTER score",
            'match_reasons' => "ALTER TABLE $suggestions_table ADD COLUMN match_reasons text AFTER status",
            'decision_at' => "ALTER TABLE $suggestions_table ADD COLUMN decision_at datetime DEFAULT NULL AFTER created_at",
        );

        foreach ($suggestion_columns as $column => $sql) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only table/column identifiers are interpolated ($column is a hardcoded array key, $suggestions_table is $wpdb->prefix + constant); schema migration on activation, caching not applicable
            $exists = $wpdb->get_results("SHOW COLUMNS FROM $suggestions_table LIKE '$column'");
            if (empty($exists)) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is a hardcoded ALTER TABLE statement built from array constants; table schema migration on activation, caching not applicable
                $wpdb->query($sql);
            }
        }

    }

    private function set_default_options()
    {
        // Default robots.txt
        if (!get_option('tam_rank_robots_txt')) {
            $default_robots = "User-agent: *\nDisallow: /wp-admin/\nAllow: /wp-admin/admin-ajax.php\n\nSitemap: " . home_url('/sitemap.xml');
            update_option('tam_rank_robots_txt', $default_robots);
        }

        // Default sitemap settings
        if (!get_option('tam_rank_sitemap_settings')) {
            update_option('tam_rank_sitemap_settings', array(
                'include_posts' => true,
                'include_pages' => true,
                'include_categories' => false,
                'include_tags' => false,
                'posts_priority' => '0.6',
                'pages_priority' => '0.8',
                'posts_frequency' => 'weekly',
                'pages_frequency' => 'monthly'
            ));
        }

        // Default general settings — seed feature toggles only on first install.
        // add_option() is a no-op when the option already exists, so reactivation
        // will not overwrite user choices. Features class is loaded on `init`, not
        // during activation, so require it here.
        if (get_option('tam_rank_general_settings', false) === false) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/class-tamrank-features.php';
            add_option('tam_rank_general_settings', TamRank_Features::get_defaults());
        }
    }

    public function tam_rank_filter_document_title($title)
    {
        $custom = $this->tam_rank_get_custom_meta_title_for_context();
        return !empty($custom) ? $custom : $title;
    }

    public function tam_rank_filter_document_title_parts($parts)
    {
        $custom = $this->tam_rank_get_custom_meta_title_for_context();
        if (!empty($custom)) {
            $parts['title'] = $custom;
        }
        return $parts;
    }

    private function tam_rank_get_custom_meta_title_for_context()
    {
        if (is_singular()) {
            $post_id = get_queried_object_id();
            $title = get_post_meta($post_id, '_tam_rank_meta_title', true);
            if (!empty($title)) {
                // Vervang dynamische variabelen in titel
                if (function_exists('tamrank_replace_dynamic_variables')) {
                    $title = tamrank_replace_dynamic_variables($title, $post_id);
                }
                return wp_strip_all_tags($title);
            }
            $append_site_name = (bool) get_option('tam_rank_append_sitename', true);
            $base = get_the_title($post_id);
            if ($append_site_name) {
                $site = get_bloginfo('name');
                return ($base && $site) ? sprintf('%s | %s', $base, $site) : $base;
            }
            return $base;
        }

        if (is_front_page()) {
            $home = get_option('tam_rank_home_title');
            if (!empty($home)) {
                return wp_strip_all_tags($home);
            }
            return get_bloginfo('name');
        }

        if (is_home()) {
            $blog = get_option('tam_rank_blog_title');
            if (!empty($blog)) {
                return wp_strip_all_tags($blog);
            }
            $page_for_posts = (int) get_option('page_for_posts');
            if ($page_for_posts) {
                return get_the_title($page_for_posts);
            }
            return __('Blog', 'tam-rank');
        }

        if (is_category() || is_tag() || is_tax()) {
            $term = get_queried_object();
            if ($term && !is_wp_error($term)) {
                $custom_title = get_term_meta($term->term_id, '_tam_rank_meta_title', true);
                if (!empty($custom_title)) {
                    return wp_strip_all_tags($custom_title);
                }
            }
            // Fallback to default WP titles
            if (is_category()) {
                $cat = single_cat_title('', false);
                /* translators: %s: category name */
                return $cat ? sprintf(__('Category: %s', 'tam-rank'), $cat) : __('Category', 'tam-rank');
            }
            if (is_tag()) {
                $tag = single_tag_title('', false);
                /* translators: %s: tag name */
                return $tag ? sprintf(__('Tag: %s', 'tam-rank'), $tag) : __('Tag', 'tam-rank');
            }
            // Custom taxonomy fallback
            if ($term && !is_wp_error($term)) {
                return $term->name;
            }
        }
        if (is_search()) {
            /* translators: %s: search query string */
            return sprintf(__('Search results for “%s”', 'tam-rank'), get_search_query());
        }
        if (is_author()) {
            $author = get_queried_object();
            if ($author && !is_wp_error($author)) {
                /* translators: %s: author display name */
                return sprintf(__('Author: %s', 'tam-rank'), $author->display_name);
            }
        }
        // GEEN is_date()-tak meer. Die deed `return wp_get_document_title();`,
        // en dat is precies de functie die `pre_get_document_title` afvuurt —
        // dus riep deze handler zichzelf aan tot het geheugen op was (gemeten op
        // een maandarchief: 47.250 her-intredes, stackdiepte 236.258, fatal op
        // 512 MB). Datumarchieven vallen nu door naar de lege string hieronder.
        //
        // Een lege string is bij `pre_get_document_title` geen lege titel maar
        // "geen override": core bouwt de titel dan gewoon zelf op
        // (get_the_date() + scheidingsteken + sitenaam), wat exact het gedrag is
        // dat de weggehaalde regel probeerde te bereiken. Ook de
        // `document_title_parts`-handler leest deze helper, en die laat de parts
        // ongemoeid zodra hier leeg terugkomt.
        //
        // Roep hier nooit een corefunctie aan die haar eigen filter opnieuw
        // afvuurt.
        return '';
    }

    /**
     * Send an X-Robots-Tag header for indexability only.
     *
     * DELIBERATE SCOPE — do not "fix" this into a mirror of the meta tag.
     *
     * This header carries noindex/nofollow and nothing else. It does NOT carry
     * the advanced directives (noarchive, nosnippet, max-snippet, ...), for two
     * reasons:
     *
     * 1. Since the directives moved to the wp_robots filter (filter_robots()),
     *    the meta tag is the negotiated result of the whole filter chain, not
     *    something we alone decide. A header built from our own meta would be a
     *    snapshot of our input only, and can therefore disagree with the tag the
     *    page actually ships — a plugin running later (Yoast hooks wp_robots at
     *    PHP_INT_MAX - 10) changes the tag but not this header.
     * 2. Where both exist, search engines combine them and the most restrictive
     *    value wins. For noindex/nofollow that is harmless: a page you wanted
     *    out of the index stays out. For the preview directives it is exactly
     *    wrong, because a stale header would silently clamp your own snippets.
     *
     * So: indexability travels over both channels, where belt-and-braces has
     * value; the SERP-presentation directives travel over the meta tag only.
     */
    public function tam_rank_send_robots_header()
    {
        // Per-singular op basis van meta, plus enkele contexten.
        foreach (headers_list() as $h) {
            if (stripos($h, 'X-Robots-Tag:') === 0) {
                return; // al aanwezig, voorkom dubbele header
            }
        }

        $noindex = false;
        $nofollow = false;
        // Alleen om te bepalen OF de pagina zelf iets heeft ingesteld; de
        // geavanceerde richtlijnen zelf reizen bewust niet over deze header,
        // zie de scope-noot hierboven.
        $advanced = array();

        if (is_singular()) {
            $post_id = get_queried_object_id();
            $noindex = (bool) get_post_meta($post_id, '_tam_rank_noindex', true);
            $nofollow = (bool) get_post_meta($post_id, '_tam_rank_nofollow', true);
            if (function_exists('tamrank_build_robots_directives')) {
                $advanced = tamrank_build_robots_directives($post_id, 'post');
            }
        } elseif (is_category() || is_tag() || is_tax()) {
            $term = get_queried_object();
            if ($term && !is_wp_error($term)) {
                $noindex = (bool) get_term_meta($term->term_id, '_tam_rank_noindex', true);
                $nofollow = (bool) get_term_meta($term->term_id, '_tam_rank_nofollow', true);
                if (function_exists('tamrank_build_robots_directives')) {
                    $advanced = tamrank_build_robots_directives($term->term_id, 'term');
                }
            }
        }

        // Dezelfde uitzondering als filter_robots(): een pagina die zelf iets
        // heeft ingesteld - al is het alleen 'noarchive' of 'max-snippet:90' -
        // mag niet stil gede-indexeerd worden door de contextuele regel
        // hieronder. Die uitzondering landde in 1.3.7 wel in filter_robots()
        // maar niet hier, en omdat zoekmachines beide kanalen combineren en de
        // strengste laat winnen, maakte deze header de metatag stil ongedaan:
        // een categorie met alleen max-snippet:90 gaf op page/2/ een metatag
        // die indexeerbaar zei naast een header 'noindex, follow'.
        $has_own = $noindex || $nofollow || !empty($advanced);

        $header_value = '';
        if ($noindex || $nofollow) {
            $robots = array();
            if ($noindex)
                $robots[] = 'noindex';
            if ($nofollow)
                $robots[] = 'nofollow';
            // default follow als alleen noindex gezet is
            if ($noindex && !$nofollow) {
                $robots[] = 'follow';
            }
            $header_value = implode(', ', array_unique($robots));
        } elseif (!$has_own && (tam_rank_context_is_noindex_archive() || is_404() || is_paged())) {
            // Same three conditions as filter_robots(), read from the same
            // helper. See the note there.
            $header_value = 'noindex, follow';
        }

        if (!apply_filters('tam_rank_send_x_robots', true)) {
            return; // feature uitgeschakeld via filter
        }

        if (!headers_sent() && !empty($header_value)) {
            header('X-Robots-Tag: ' . $header_value, true);
        }
    }

}

// Initialize the plugin
new TamRank_Plugin();

// Activation hook - flush rewrite rules for sitemap + onboarding redirect
register_activation_hook(__FILE__, function () {
    flush_rewrite_rules();
    set_transient('tamrank_activation_redirect', true, 30);
});
