<?php
/**
 * Plugin Name: Rank Forge – SEO Score, Schema, Sitemap & Redirects
 * Plugin URI: https://rankforgewp.com
 * Description: On-page SEO toolkit for WordPress: SEO score, meta box, SERP preview, schema markup, XML sitemap, robots.txt, redirects, image SEO, local business and video schema. Optional Pro add-on adds AI meta generation, Site Audit, GSC, and more.
 * Version: 1.1.0
 * Author: Avakode
 * Author URI: https://avakode.com
 * Text Domain: rankforge
 * Domain Path: /languages
 * Requires at least: 6.5
 * Requires PHP: 7.4
 * License: GPLv2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 */

if (!defined('ABSPATH')) {
    exit;
}

define('RANKFORGE_VERSION', '1.1.0');
// RANKFORGE_DB_VERSION moves independently of the user-facing plugin
// version and bumps only when a CREATE TABLE block changes shape.
// See RANKFORGE_Schema_Manager for the per-table version pins.
define('RANKFORGE_DB_VERSION', '1.0.1');
define('RANKFORGE_DIR', plugin_dir_path(__FILE__));
define('RANKFORGE_URL', plugin_dir_url(__FILE__));
define('RANKFORGE_BASENAME', plugin_basename(__FILE__));
// Shared ed25519 public key for verifying signed auto-update packages (#8/M6).
// Same value across all Forge plugins → guard against double-define when several
// are active. Verification stays inert until a release manifest carries a signature.
if (!defined('FORGE_UPDATE_PUBKEY')) define('FORGE_UPDATE_PUBKEY', '54EEDBTTEytITaNTDKTcP+ugpv9JFBOLAJRJMeLGaQo=');

/**
 * Safe replacement for maybe_unserialize: mirrors its semantics
 * (non-serialized values pass through) but never instantiates objects,
 * blocking PHP object-injection via unserialize gadget chains. Shared
 * across all Forge plugins behind a function_exists guard.
 */
if (!function_exists('forge_safe_unserialize')) {
    function forge_safe_unserialize($value) {
        if (!is_string($value)) {
            return $value;
        }
        return is_serialized($value)
            ? unserialize($value, ['allowed_classes' => false])
            : $value;
    }
}

if (!function_exists('forge_kses_credit_badge')) {
    /**
 * Allowed-HTML list for echoing trusted, self-built credit-cost badge and
 * AI-button inner markup at output time (covers the inline SVG icon that
 * wp_kses_post would strip). Every dynamic value in that markup is already
 * esc_attr'd / integer-cast by its builder; this is the late-escape gate.
 */
    function forge_kses_credit_badge() {
        $attr = array(
            'class' => true, 'style' => true, 'title' => true, 'id' => true,
            'role' => true, 'aria-hidden' => true, 'aria-label' => true,
            'data-cost' => true, 'data-action' => true, 'data-slug' => true,
        );
        return array(
            'span' => $attr, 'strong' => $attr, 'small' => $attr, 'b' => $attr, 'em' => $attr,
            'svg'  => array('class'=>true,'style'=>true,'width'=>true,'height'=>true,'viewbox'=>true,'fill'=>true,'stroke'=>true,'xmlns'=>true,'aria-hidden'=>true,'role'=>true,'focusable'=>true),
            'path' => array('d'=>true,'fill'=>true,'fill-rule'=>true,'clip-rule'=>true,'stroke'=>true,'stroke-width'=>true,'stroke-linecap'=>true,'stroke-linejoin'=>true,'opacity'=>true),
            'g' => array('fill'=>true,'stroke'=>true,'transform'=>true),
            'circle' => array('cx'=>true,'cy'=>true,'r'=>true,'fill'=>true,'stroke'=>true),
            'rect' => array('x'=>true,'y'=>true,'width'=>true,'height'=>true,'rx'=>true,'fill'=>true),
        );
    }
}


// Pro Bridge — the Free side of the Free/Pro extension contract.
// Loaded before any other class so License + everything downstream can
// consult it. See SPLIT_PLAN.md §5.3.

require_once RANKFORGE_DIR . 'includes/class-pro-bridge.php';
RANKFORGE_Pro_Bridge::instance();

// FC-63: shared license resolver — single source of truth for licensing UI +
// gating. First Forge plugin to load wins via the class_exists wrap.
if (!class_exists('Forge_License_Resolver')) {
    require_once RANKFORGE_DIR . 'includes/class-forge-license-resolver.php';
}

// Autoload
spl_autoload_register(function ($class) {
    $prefix = 'RANKFORGE_';
    if (strpos($class, $prefix) !== 0) return;
    $name = strtolower(str_replace('_', '-', str_replace($prefix, '', $class)));
    $file = RANKFORGE_DIR . 'includes/class-' . $name . '.php';
    if (file_exists($file)) { require_once $file; return; }
    $admin = RANKFORGE_DIR . 'admin/class-' . $name . '.php';
    if (file_exists($admin)) { require_once $admin; }
});

// Forge Queue — load before plugins_loaded so the table exists early
if (!class_exists('Forge_Queue')) {
    require_once RANKFORGE_DIR . 'includes/class-forge-queue.php';
}

// Shared transactional email renderer (first Forge plugin to load wins).
if (!class_exists('Forge_Email_Template')) {
    require_once RANKFORGE_DIR . 'includes/class-forge-email.php';
}

// Shared SSRF-safe wp_remote_* wrapper (first Forge plugin to load wins).
if (!class_exists('Forge_Net')) {
    require_once RANKFORGE_DIR . 'includes/class-forge-net.php';
}

// Shared opt-in telemetry (first Forge plugin to load wins). Off by default —
// nothing is sent without explicit consent. See class-forge-telemetry.php.
if (!class_exists('Forge_Telemetry') && file_exists(RANKFORGE_DIR . 'includes/class-forge-telemetry.php')) {
    require_once RANKFORGE_DIR . 'includes/class-forge-telemetry.php';
}
if (class_exists('Forge_Telemetry')) {
    Forge_Telemetry::watch_plugin_errors(RANKFORGE_DIR);
    register_activation_hook(__FILE__,   ['Forge_Telemetry', 'on_activate']);
    register_deactivation_hook(__FILE__, ['Forge_Telemetry', 'on_deactivate']);
}

// Schema upgrades — runs idempotently on every request, but the fast-path
// is two get_option calls once the install is at the current version.
add_action( 'plugins_loaded', function () {
    // Must precede the schema manager. On a site upgraded from the SEO Forge
    // name every key still carries the old prefix, including the version
    // option the manager reads — left alone it would read "not installed",
    // build empty tables beside the full ones and orphan all existing data.
    require_once RANKFORGE_DIR . 'includes/class-rename-migration.php';
    RANKFORGE_Rename_Migration::maybe_run();

    require_once RANKFORGE_DIR . 'includes/class-schema-manager.php';
    RANKFORGE_Schema_Manager::maybe_upgrade();
}, 1 );

// Init
function rankforge_init() {
    // phpcs:ignore PluginCheck.CodeAnalysis.DiscouragedFunctions.load_plugin_textdomainFound -- needed for bundled .mo on the Avakode bridge/Pro channel
    load_plugin_textdomain('rankforge', false, dirname(RANKFORGE_BASENAME) . '/languages');
    // Shared Forge UI (class-forge-*.php) uses the 'forge' textdomain — alias it
    // to this plugin's own compiled catalog so the shared dashboard/credits/
    // connect/feedback/updater strings translate (they're extracted into this
    // plugin's .po/.mo too). Without this, 'forge' loads nowhere → English-only.
    // Prefer the community language pack from translate.wordpress.org when one
    // exists: WordPress installs it into WP_LANG_DIR/plugins/ and it already
    // overrides our bundled catalog for the 'rankforge' textdomain, so reading the
    // bundled file here would alias 'forge' to an older, unreviewed translation.
    $forge_locale = determine_locale();
    $sf_forge_mo = WP_LANG_DIR . '/plugins/rankforge-' . $forge_locale . '.mo';
    if (!is_readable($sf_forge_mo)) {
        $sf_forge_mo = RANKFORGE_DIR . 'languages/rankforge-' . $forge_locale . '.mo';
    }
    if (is_readable($sf_forge_mo)) { load_textdomain('forge', $sf_forge_mo); }

    // Initialize license (must be first — other classes check license state)
    RANKFORGE_License::instance();

    RANKFORGE_Core::instance();
    RANKFORGE_Analyzer::instance();
    RANKFORGE_Meta_Templates::instance();
    RANKFORGE_Meta_Generator::instance();
    RANKFORGE_Competitor_Coexistence::instance();
    RANKFORGE_Schema::instance();
    RANKFORGE_Content_Scorer::instance();
    RANKFORGE_Rest_Api::instance();
    RANKFORGE_Dashboard::instance();
    RANKFORGE_Robots_Meta::instance();
    RANKFORGE_Sitemap::instance();
    RANKFORGE_Robots_Txt::instance();
    RANKFORGE_Breadcrumbs::instance();

    // 2026-05-29 Free/Pro rebalance — pure-local SEO features that ship in
    // the Free plugin (parity with Rank Math / AIOSEO / SEOPress / Slim SEO).
    // Each class self-gates via RANKFORGE_License::can in its constructor,
    // and those slugs are now in FREE_FEATURES, so they wire their hooks on
    // both Free-only and Free+Pro sites. No AI/Worker calls inside them — the
    // credit-spending AI sub-actions keep their own 'ai_features' gate.
    RANKFORGE_Image_Seo::instance();   // auto-alt from filename + media reminder
    RANKFORGE_Redirects::instance();   // 301/302/307 manager + 404 log
    RANKFORGE_Local_Seo::instance();   // LocalBusiness JSON-LD + admin page
    RANKFORGE_Video_Seo::instance();   // VideoObject schema + video sitemap

    // Forge Queue handlers — Free-side only. GSC sync handler is registered
    // by the Pro add-on (RANKFORGE_GSC lives in rankforge-pro), so we only
    // wire the Free-side scan_links_batch here.
    Forge_Queue::instance();
    Forge_Queue::register('scan_links_batch', [RANKFORGE_Core::class, 'process_scan_batch']);

    if (is_admin()) {
        $rankforge_dashboard_file = RANKFORGE_DIR . 'includes/class-forge-dashboard.php';
        if (is_readable($rankforge_dashboard_file)) {
            require_once $rankforge_dashboard_file;
            if (class_exists('Forge_Suite_Dashboard')) {
                Forge_Suite_Dashboard::instance(); // Register AJAX hooks early (needed for admin-ajax.php)
            }
        }
        require_once RANKFORGE_DIR . 'includes/class-forge-docs-link.php';
        require_once RANKFORGE_DIR . 'includes/class-forge-support-link.php';
        require_once RANKFORGE_DIR . 'includes/class-forge-pro-tip.php';
        $rankforge_feedback_file = RANKFORGE_DIR . 'includes/class-forge-feedback-widget.php';
        if (is_readable($rankforge_feedback_file)) {
            require_once $rankforge_feedback_file;
        }
        RANKFORGE_Admin::instance();
        RANKFORGE_License_Admin::instance();
        require_once RANKFORGE_DIR . 'includes/class-migration.php';
        RANKFORGE_Migration::instance();
    }
}
add_action('plugins_loaded', 'rankforge_init', 5);

// Activation
register_activation_hook(__FILE__, function () {
    // Same ordering as on plugins_loaded: take over the old name's data before
    // the schema manager forms an opinion about whether this is a fresh site.
    require_once RANKFORGE_DIR . 'includes/class-rename-migration.php';
    RANKFORGE_Rename_Migration::maybe_run();

    // All schema work flows through the version-pinned manager so first
    // install and version-bump upgrades take the same code path.
    require_once RANKFORGE_DIR . 'includes/class-schema-manager.php';
    RANKFORGE_Schema_Manager::maybe_upgrade();
    flush_rewrite_rules();

    // Telemetry is Pro-only (moved to rankforge-pro). On Free+Pro sites the
    // class is loaded; on Free-only sites we just stamp the install version.
    if (class_exists('Forge_Telemetry')) {
        $prev = (string) get_option('rankforge_previous_version', '');
        Forge_Telemetry::track('plugin.activated', [
            'first_install'    => $prev === '',
            'previous_version' => $prev,
        ]);
    }
    update_option('rankforge_previous_version', RANKFORGE_VERSION, false);
    if (!get_option('rankforge_installed_at')) {
        update_option('rankforge_installed_at', time(), false);
    }
});

register_deactivation_hook(__FILE__, function () {
    // B9: clear scheduled cron jobs so wp-cron doesn't keep firing on
    // callbacks whose classes no longer load — without this every cron tick
    // throws a fatal "class RANKFORGE_GSC not found" until reactivation.
    wp_clear_scheduled_hook('rankforge_daily_gsc_sync');

    // Keep data. Telemetry is Pro-only post-split — fire only when present.
    if (class_exists('Forge_Telemetry')) {
        $installed_at = (int) get_option('rankforge_installed_at', time());
        Forge_Telemetry::track('plugin.deactivated', [
            'uptime_days' => max(0, (int) floor((time() - $installed_at) / DAY_IN_SECONDS)),
        ]);
        Forge_Telemetry::flush();
    }
});
