<?php
/**
 * Plugin Name: SEO Forge
 * Plugin URI: https://seoforgewp.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.0.29
 * Author: Avakode
 * Author URI: https://avakode.com
 * Text Domain: seoforge
 * 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('SEOFORGE_VERSION', '1.0.29');
// SEOFORGE_DB_VERSION moves independently of the user-facing plugin
// version and bumps only when a CREATE TABLE block changes shape.
// See SEOFORGE_Schema_Manager for the per-table version pins.
define('SEOFORGE_DB_VERSION', '1.0.1');
define('SEOFORGE_DIR', plugin_dir_path(__FILE__));
define('SEOFORGE_URL', plugin_dir_url(__FILE__));
define('SEOFORGE_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;
    }
}


// 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 SEOFORGE_DIR . 'includes/class-pro-bridge.php';
SEOFORGE_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 SEOFORGE_DIR . 'includes/class-forge-license-resolver.php';
}

// Autoload
spl_autoload_register(function ($class) {
    $prefix = 'SEOFORGE_';
    if (strpos($class, $prefix) !== 0) return;
    $name = strtolower(str_replace('_', '-', str_replace($prefix, '', $class)));
    $file = SEOFORGE_DIR . 'includes/class-' . $name . '.php';
    if (file_exists($file)) { require_once $file; return; }
    $admin = SEOFORGE_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 SEOFORGE_DIR . 'includes/class-forge-queue.php';
}

// Shared transactional email renderer (first Forge plugin to load wins).
if (!class_exists('Forge_Email_Template')) {
    require_once SEOFORGE_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 SEOFORGE_DIR . 'includes/class-forge-net.php';
}

// 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 () {
    require_once SEOFORGE_DIR . 'includes/class-schema-manager.php';
    SEOFORGE_Schema_Manager::maybe_upgrade();
}, 1 );

// Init
function seoforge_init() {
    // phpcs:ignore PluginCheck.CodeAnalysis.DiscouragedFunctions.load_plugin_textdomainFound -- needed for bundled .mo on the Avakode bridge/Pro channel
    load_plugin_textdomain('seoforge', false, dirname(SEOFORGE_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.
    $sf_forge_mo = SEOFORGE_DIR . 'languages/seoforge-' . determine_locale() . '.mo';
    if (is_readable($sf_forge_mo)) { load_textdomain('forge', $sf_forge_mo); }

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

    SEOFORGE_Core::instance();
    SEOFORGE_Analyzer::instance();
    SEOFORGE_Meta_Templates::instance();
    SEOFORGE_Meta_Generator::instance();
    SEOFORGE_Competitor_Coexistence::instance();
    SEOFORGE_Schema::instance();
    SEOFORGE_Content_Scorer::instance();
    SEOFORGE_Rest_Api::instance();
    SEOFORGE_Dashboard::instance();
    SEOFORGE_Robots_Meta::instance();
    SEOFORGE_Sitemap::instance();
    SEOFORGE_Robots_Txt::instance();
    SEOFORGE_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 SEOFORGE_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.
    SEOFORGE_Image_Seo::instance();   // auto-alt from filename + media reminder
    SEOFORGE_Redirects::instance();   // 301/302/307 manager + 404 log
    SEOFORGE_Local_Seo::instance();   // LocalBusiness JSON-LD + admin page
    SEOFORGE_Video_Seo::instance();   // VideoObject schema + video sitemap

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

    if (is_admin()) {
        $seoforge_dashboard_file = SEOFORGE_DIR . 'includes/class-forge-dashboard.php';
        if (is_readable($seoforge_dashboard_file)) {
            require_once $seoforge_dashboard_file;
            if (class_exists('Forge_Suite_Dashboard')) {
                Forge_Suite_Dashboard::instance(); // Register AJAX hooks early (needed for admin-ajax.php)
            }
        }
        require_once SEOFORGE_DIR . 'includes/class-forge-docs-link.php';
        $seoforge_feedback_file = SEOFORGE_DIR . 'includes/class-forge-feedback-widget.php';
        if (is_readable($seoforge_feedback_file)) {
            require_once $seoforge_feedback_file;
        }
        SEOFORGE_Admin::instance();
        SEOFORGE_License_Admin::instance();
        require_once SEOFORGE_DIR . 'includes/class-migration.php';
        SEOFORGE_Migration::instance();
    }
}
add_action('plugins_loaded', 'seoforge_init', 5);

// Activation
register_activation_hook(__FILE__, function () {
    // All schema work flows through the version-pinned manager so first
    // install and version-bump upgrades take the same code path.
    require_once SEOFORGE_DIR . 'includes/class-schema-manager.php';
    SEOFORGE_Schema_Manager::maybe_upgrade();
    flush_rewrite_rules();

    // Telemetry is Pro-only (moved to seoforge-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('seoforge_previous_version', '');
        Forge_Telemetry::track('plugin.activated', [
            'first_install'    => $prev === '',
            'previous_version' => $prev,
        ]);
    }
    update_option('seoforge_previous_version', SEOFORGE_VERSION, false);
    if (!get_option('seoforge_installed_at')) {
        update_option('seoforge_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 SEOFORGE_GSC not found" until reactivation.
    wp_clear_scheduled_hook('seoforge_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('seoforge_installed_at', time());
        Forge_Telemetry::track('plugin.deactivated', [
            'uptime_days' => max(0, (int) floor((time() - $installed_at) / DAY_IN_SECONDS)),
        ]);
        Forge_Telemetry::flush();
    }
});
