<?php
/**
 * Forge Suite — activation / deactivation AJAX handlers.
 *
 * Extracted from class-forge-dashboard.php (F13 phase 3 of the
 * decomposition plan — forge-meta/DECOMPOSITION_PLAN.md). The code
 * moved verbatim; the wiring moved from `Forge_Suite_Dashboard`'s
 * instance methods to static handlers registered on `wp_ajax_*` here.
 *
 * Why a separate class: both methods use Reflection to call private
 * Freemius SDK methods (`activate_license`, `_deactivate_license`,
 * `get_api_site_scope`). Isolating them lets us test + reason about
 * that risk surface without the rest of the 1500-line file fighting
 * for attention. Same reason `fs_slug_is_clean` + `fs_force_wipe`
 * live here rather than on the dashboard facade — they're only used
 * by deactivation.
 *
 * Loaded once across the 4 plugins via `class_exists` guard. Any one
 * plugin being active is enough to register the `wp_ajax_*` handlers.
 */

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

// phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
// PHP 8.3 hoist-safe guard: wrap the declaration inside an if block so the
// second-include-via-different-path case doesn't hit a compile-time
// "Cannot declare class, already in use" fatal.
if (!class_exists('Forge_Suite_Dashboard_Activation')) {

class Forge_Suite_Dashboard_Activation {
    private static $registered = false;

    /** Register the AJAX handlers. Idempotent — safe to call from every
 * sibling plugin's bootstrap. */
    public static function register() {
        if (self::$registered) return;
        self::$registered = true;
        // FC-63 v2: manual bundle-key activation removed — Connect is the only
        // activation path now. Only the full-site Disconnect handler remains.
        add_action('wp_ajax_forge_suite_deactivate_bundle', [__CLASS__, 'handle_deactivate']);
    }

    /**
 * AJAX: Deactivate license across all plugins.
 */
    public static function handle_deactivate() {
        check_ajax_referer('forge_suite_deactivate_bundle');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Unauthorized', 'rankforge'));
        }

        // F8: one-shot lock. WordPress nonces are tied to user+action and
        // valid for ~24h, so the same nonce passes check_ajax_referer many
        // times. A user double-clicking Deactivate fires two full
        // Reflection + tier-2 fallback passes concurrently, which can race
        // on fs_accounts rewrites and surface a spurious error on the
        // second response. Short-lived transient per user blocks the second
        // request cleanly.
        $user_id  = get_current_user_id();
        $lock_key = 'forge_deact_lock_' . $user_id;
        if (get_transient($lock_key)) {
            wp_send_json_error([
                'message' => __('Deactivation already in progress — please wait a moment and reload.', 'rankforge'),
                'code'    => 'in_progress',
            ]);
        }
        set_transient($lock_key, 1, 30);

        $fs_map = [
            'seo'        => 'sf_freemius',
            'lang'       => 'langforge_freemius',
            'form'       => 'ff_freemius',
            'fieldforge' => 'fdf_freemius',
        ];

        $deactivated = 0;
        $forced      = 0;
        $debug       = [];

        try {

        foreach ($fs_map as $key => $fn) {
            if (!function_exists($fn)) continue;

            $fs = call_user_func($fn);

            if (!$fs->is_paying() && !$fs->is_trial()) continue;

            $slug      = method_exists($fs, 'get_slug') ? (string) $fs->get_slug() : '';
            $module_id = method_exists($fs, 'get_id')   ? (int) $fs->get_id()      : 0;
            $site      = method_exists($fs, 'get_site') ? $fs->get_site() : null;

            if (!self::fs_site_matches_current_install($site)) {
                if (($slug !== '' || $module_id > 0) && self::fs_force_wipe($slug, $module_id)) {
                    $forced++;
                    $deactivated++;
                    $debug[$slug ?: "pid=$module_id"] = 'stale_install_local_wipe';
                } else {
                    $debug[$slug ?: "pid=$module_id"] = 'stale_install_skip_remote_failed';
                }
                continue;
            }

            // ── Tier 1: polite deactivation via Freemius SDK ──────────
            // Reflection into protected _deactivate_license. Note:
            // this method rarely throws — it records errors internally
            // and returns "success". So we can't rely on try/catch for
            // the fall-through. Instead we verify local state after the
            // call and re-tier if still dirty.
            try {
                $method = new ReflectionMethod(get_class($fs), '_deactivate_license');
                $method->setAccessible(true);
                $method->invoke($fs, false);
            } catch (\Exception $e) {
                // ignore — we check state below anyway
            }

            if (self::fs_slug_is_clean($slug, $module_id)) {
                $deactivated++;
                $debug[$slug] = 'tier1_ok';
                continue;
            }

            // ── Tier 2: direct API DELETE via site-scope ──────────────
            // `get_api_site_scope` is `private` in Freemius SDK ≥2.13.x
            // (was `public` earlier). Direct call throws
            // "Call to private method" from our class context. Reflection
            // is the same trick used for _deactivate_license in tier 1.
            if ($fs->is_registered()) {
                try {
                    if (isset($site->license_id) && $site->license_id) {
                        $scope_m = new ReflectionMethod(get_class($fs), 'get_api_site_scope');
                        $scope_m->setAccessible(true);
                        $api = $scope_m->invoke($fs);
                        if ($api && is_object($api) && method_exists($api, 'call')) {
                            $resp = $api->call("/licenses/{$site->license_id}.json", 'delete');
                            if (empty($resp) || !isset($resp->error)) {
                                $fs->_sync_licenses();
                            }
                        }
                    }
                } catch (\Throwable $e) {
                    // fall through to tier 3 — log in WP_DEBUG mode
                    if (defined('WP_DEBUG') && WP_DEBUG) {

                    }
                }
            }

            if (self::fs_slug_is_clean($slug, $module_id)) {
                $deactivated++;
                $debug[$slug] = 'tier2_ok';
                continue;
            }

            // ── Tier 3: force-wipe LOCAL Freemius state ───────────────
            // License was cancelled/deleted on Freemius side, so neither
            // SDK nor API can confirm deactivation. Blow the plugin's
            // rows out of every sub-store in `fs_accounts` + per-plugin
            // options. Surgical per-plugin — sibling plugins stay intact.
            // Must pass BOTH slug AND module_id (plugin_id) because half
            // the sub-stores key by slug, the other half by module_id.
            if (($slug !== '' || $module_id > 0) && self::fs_force_wipe($slug, $module_id)) {
                $forced++;
                $deactivated++;
                $debug[$slug ?: "pid=$module_id"] = 'tier3_force_wipe';
            } else {
                $debug[$slug ?: "pid=$module_id"] = 'tier3_failed';
            }
        }

        if ($deactivated > 0) {
            // (2026-04-30): clear the cross-plugin grace-period
            // marker so the per-plugin License page stops reporting "Pro"
            // 24h after deactivation. Without this, LANGFORGE_License's
            // effective_plan reads `forge_license_last_pro_at` as a
            // recent confirmation and stays on `pro` until the next
            // Freemius reconfirm — causing the dashboard / per-plugin
            // License page divergence the tester reported.
            delete_option('forge_license_last_pro_at');
            // Also drop any stale Forge-Connect sync errors so the
            // license page doesn't render a phantom warning panel.
            delete_option('forge_connect_sync_errors');


            // drops the Avakode account binding, cached credits and the grace
            // timestamp, AND rewrites every product's license_data to an inactive
            // cache (key kept for instant Reconnect, never shown as active). One
            // call — Forge_Connect::disconnect_all — owns all of that so the
            // dashboard and every License page show a clean disconnected/Free
            // state (no "Connected" left over, credits widget gone). Fall back to
            // the binding/credits clear if the Pro add-on isn't loaded.
            if (class_exists('Forge_Connect') && method_exists('Forge_Connect', 'disconnect_all')) {
                Forge_Connect::disconnect_all();
            } else {
                delete_option('forge_account_binding');
                if (class_exists('Forge_Credits_Widget') && method_exists('Forge_Credits_Widget', 'invalidate')) {
                    Forge_Credits_Widget::invalidate();
                }
                if (class_exists('Forge_License_Resolver')) {
                    Forge_License_Resolver::flush();
                }
            }

            $msg = $forced > 0
                /* translators: 1: number of plugins whose license was deactivated, 2: number of those that were force-reset due to server-side cancellation. */
                ? sprintf(__('License deactivated for %1$d plugin(s) (%2$d force-reset due to server-side cancellation). Page will reload.', 'rankforge'), $deactivated, $forced)
                /* translators: %d: number of plugins whose license was deactivated. */
                : sprintf(__('License deactivated for %d plugin(s). Page will reload.', 'rankforge'), $deactivated);
            delete_transient($lock_key);
            wp_send_json_success([
                'message' => $msg,
                'reload'  => true,
                'debug'   => $debug,
            ]);
        } else {
            delete_transient($lock_key);
            wp_send_json_error([
                'message' => __('No active licenses found to deactivate.', 'rankforge'),
                'debug'   => $debug,
            ]);
        }

        } catch (\Throwable $e) {
            // Best-effort: surface the message, release the lock so the user
            // can retry. wp_send_json_error exits, so finally wouldn't help
            // here anyway.
            delete_transient($lock_key);
            wp_send_json_error([
                'message' => __('Deactivation failed. Please try again.', 'rankforge'),
                'debug'   => array_merge($debug, ['exception' => $e->getMessage()]),
            ]);
        }
    }

    /**
 * Is the given Freemius plugin absent from every sub-store in
 * fs_accounts? Checks BOTH slug-keyed stores (sites, plans,
 * plugin_data, admin_notices, …) AND module-id-keyed stores
 * (all_licenses, user_id_license_ids_map, updates, id_slug_type_path_map).
 * The tier gate — we don't trust the SDK's return value, we trust the
 * state on disk.
 */
    private static function fs_slug_is_clean($slug, $module_id = 0) {
        // Force read (no object-cache hit) so we see what we just wrote.
        wp_cache_delete('fs_accounts', 'options');
        $accounts = get_option('fs_accounts', []);
        if (!is_array($accounts)) return true;

        if ($slug !== '') {
            foreach (['sites', 'plans', 'plugin_data', 'trial_plans', 'licenses'] as $s) {
                if (!empty($accounts[$s][$slug])) return false;
            }
        }
        if ($module_id > 0) {
            foreach (['all_licenses', 'user_id_license_ids_map', 'updates'] as $s) {
                if (!empty($accounts[$s][$module_id])
                    || !empty($accounts[$s][(string) $module_id])) return false;
            }
        }
        return true;
    }

    /**
 * A cloned WP install can carry another site's Freemius install id in
 * fs_accounts. In that state even install-scope Freemius calls target the
 * original remote install, so deactivation must stay local-only.
 */
    private static function fs_site_matches_current_install($site) {
        if (!is_object($site) || empty($site->url)) {
            return true;
        }

        $stored = self::normalize_site_url_for_compare((string) $site->url);
        if ($stored === '') {
            return true;
        }

        $candidates = [];
        if (function_exists('site_url')) {
            $candidates[] = site_url();
        }
        if (function_exists('home_url')) {
            $candidates[] = home_url('/');
        }

        foreach ($candidates as $candidate) {
            if ($stored === self::normalize_site_url_for_compare((string) $candidate)) {
                return true;
            }
        }

        return false;
    }

    private static function normalize_site_url_for_compare($url) {
        $url = trim((string) $url);
        if ($url === '') {
            return '';
        }

        $parts = wp_parse_url($url);
        if (!is_array($parts) || empty($parts['host'])) {
            return rtrim(strtolower($url), '/');
        }

        $host = strtolower((string) $parts['host']);
        $port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
        $path = isset($parts['path']) ? '/' . trim((string) $parts['path'], '/') : '';

        return $host . $port . rtrim($path, '/');
    }

    /**
 * Purge every trace of a Freemius plugin from local WP storage.
 * Returns true on success (one or more rows removed), false if nothing
 * matched. Intentionally aggressive — runs only when both polite
 * deactivation paths failed, i.e. the license is already gone server-side.
 *
 * `fs_accounts` is a hairy store: some sub-stores are keyed by plugin
 * SLUG (string, e.g. 'langforge'), others by MODULE_ID (int, e.g. 27142).
 * Wiping only by slug leaves zombie entries in `all_licenses[27142]`,
 * `user_id_license_ids_map[27142]`, `updates[27142]` etc. — next page
 * load, Freemius SDK reads those and the install remounts as "PRO
 * active" even though our slug-keyed stores are clean.
 *
 * Strategy: accept BOTH $slug and $module_id, wipe each sub-store with
 * the key type it actually uses.
 */
    private static function fs_force_wipe($slug, $module_id = 0) {
        if ($slug === '' && $module_id <= 0) return false;
        $changed = false;

        $accounts = get_option('fs_accounts', []);
        if (is_array($accounts)) {
            // Slug-keyed sub-stores (Freemius SDK conventions across
            // versions — unsetting a missing key is a no-op).
            $slug_stores = [
                'plugin_data', 'sites', 'licenses', 'plans',
                'trial_plans', 'active_plugins', 'unknown_licenses',
                'users', 'addons', 'file_slug_map', 'prev_user_id',
                'subscriptions', 'admin_notices', 'plugins',
            ];
            if ($slug !== '') {
                foreach ($slug_stores as $store) {
                    if (isset($accounts[$store]) && is_array($accounts[$store]) && isset($accounts[$store][$slug])) {
                        unset($accounts[$store][$slug]);
                        $changed = true;
                    }
                }
            }

            // Module-id-keyed sub-stores. Keys are integers like 27142.
            $id_stores = [
                'all_licenses',
                'user_id_license_ids_map',
                'updates',
                'id_slug_type_path_map',
            ];
            if ($module_id > 0) {
                foreach ($id_stores as $store) {
                    if (isset($accounts[$store]) && is_array($accounts[$store])
                        && (isset($accounts[$store][$module_id]) || isset($accounts[$store][(string) $module_id]))) {
                        unset($accounts[$store][$module_id]);
                        unset($accounts[$store][(string) $module_id]);
                        $changed = true;
                    }
                }
            }

            if ($changed) {
                update_option('fs_accounts', $accounts, false);
                wp_cache_delete('fs_accounts', 'options');
            }
        }

        // Per-plugin anchor options that some SDK versions keep outside
        // fs_accounts. Deleting a missing option is a no-op.
        if ($slug !== '') {
            $opts = [
                "fs_{$slug}_options",
                "fs_accounts_{$slug}",
                "{$slug}_license_data",          // our own plugin-side cache
                "{$slug}_fs_accounts",
            ];
            foreach ($opts as $opt) {
                if (get_option($opt, null) !== null) {
                    delete_option($opt);
                    $changed = true;
                }
            }
        }

        // Flush any in-memory Freemius singleton state.
        if (class_exists('Freemius') && $slug !== '') {
            try {
                $prop = new \ReflectionProperty('Freemius', '_instances');
                $prop->setAccessible(true);
                $instances = $prop->getValue();
                if (is_array($instances) && isset($instances[$slug])) {
                    unset($instances[$slug]);
                    $prop->setValue(null, $instances);
                    $changed = true;
                }
            } catch (\Throwable $e) {
                // Non-fatal; fresh page load will rebuild instances.
            }
        }

        return $changed;
    }
}

} // end: if (!class_exists('Forge_Suite_Dashboard_Activation'))
