Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
36.00% covered (danger)
36.00%
27 / 75
54.55% covered (warning)
54.55%
6 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Plugin
35.62% covered (danger)
35.62%
26 / 73
54.55% covered (warning)
54.55%
6 / 11
151.17
0.00% covered (danger)
0.00%
0 / 1
 instance
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 enqueuePublicAssets
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 enqueueAdminAssets
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 activate
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 deactivate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 resetState
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onDeactivatedPlugin
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 sendDeactivationDisconnect
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
5.23
1<?php
2/**
3 * Main Plugin bootstrap class (client).
4 *
5 * @package SwapAds\Client
6 */
7
8declare(strict_types=1);
9
10namespace SwapAds\Client;
11
12use SwapAds\Client\Admin\BacklinkApprovalPage;
13use SwapAds\Client\Admin\BacklinkCreatePage;
14use SwapAds\Client\Admin\BacklinksHubPage;
15use SwapAds\Client\Admin\BacklinkStatsWidget;
16use SwapAds\Client\Admin\OnboardingWizardPage;
17use SwapAds\Client\Admin\OperatorDashboardHubPage;
18use SwapAds\Client\Admin\OperatorDashboardPage;
19use SwapAds\Client\Admin\SettingsPage;
20use SwapAds\Client\Admin\SourceCheckPage;
21use SwapAds\Client\Admin\ActivityLogPage;
22use SwapAds\Client\Blocks\BacklinksBlock;
23use SwapAds\Client\License\LicenseHeartbeat;
24use SwapAds\Client\License\FreemiusAutoActivator;
25use SwapAds\Client\License\FreemiusWebhookForwarder;
26use SwapAds\Client\License\LicenseWebhookReceiver;
27
28if (!defined('ABSPATH')) {
29    exit;
30}
31
32/**
33 * Class Plugin
34 *
35 * Main plugin bootstrap. Singleton pattern.
36 *
37 * @since 1.0.0
38 */
39final class Plugin
40{
41    private static ?Plugin $instance = null;
42
43    public static function instance(): Plugin
44    {
45        if (self::$instance === null) {
46            self::$instance = new self();
47        }
48        return self::$instance;
49    }
50
51    private function __construct() {}
52
53    /**
54     * Initialize the plugin.
55     */
56    public function init(): void
57    {
58        // F123: Backlinks shortcode (server-side rendered list of approved backlinks).
59        \SwapAds\Client\Shortcodes\BacklinksShortcode::register();
60        // F236 (2026-07-31): new Gutenberg block for backlinks display.
61        BacklinksBlock::register();
62        // F238 (2026-07-31): backlink statistics flush endpoint + JS bundle.
63        \SwapAds\Client\Statistics\StatisticsFlushEndpoint::register();
64        add_action('wp_enqueue_scripts', [self::class, 'enqueuePublicAssets']);
65        // License heartbeat (keeps license active via WP Cron)
66        LicenseHeartbeat::register();
67        // CF17: Auto-activate license via Freemius SDK after opt-in.
68        FreemiusAutoActivator::register();
69        // Plg-001 (2026-08-01): single-shot first-activation cron hook.
70        add_action('swapads_client_first_activation', [FreemiusAutoActivator::class, 'retryNow']);
71        // CF17.1 Option B: forward Freemius webhook events to server.
72        FreemiusWebhookForwarder::register();
73        // F118 (2026-08-01): receive server -> client license-status changes.
74        LicenseWebhookReceiver::register();
75        // CF17: admin-post handler for 'Retry activation' button.
76        add_action('admin_post_swapads_client_retry_activation', [SettingsPage::class, 'handleRetryActivation']);
77        // Admin settings UI
78        if (is_admin()) {
79            // F201: tabbed hub with Balance + Setup tabs (FIRST submenu).
80            OperatorDashboardHubPage::register();
81            // Back-compat: old SettingsPage registers a redirect.
82            SettingsPage::register();
83            // Back-compat: old Dashboard submenu stays as no-op (hub owns menu).
84            OperatorDashboardPage::register();
85            // F200: tabbed Backlinks UI replaces separate Create + Approval menus.
86            BacklinksHubPage::register();
87            BacklinkCreatePage::register();
88            BacklinkApprovalPage::register();
89            // F120 (2026-08-01): two-way transparency view (who approved MY
90            // backlinks). Renders as a 3rd tab on the Backlinks Hub + as a
91            // hidden submenu of the operator dashboard hub.
92            \SwapAds\Client\Admin\ApprovalsHubPage::register();
93            OnboardingWizardPage::register();
94            SourceCheckPage::register();
95            // F230 (2026-07-31): operator-facing activity log viewer.
96            ActivityLogPage::register();
97            // F238 (2026-07-31): wp-admin dashboard widget with backlink statistics.
98            BacklinkStatsWidget::register();
99            // CF14 (UX), F199/F203: enqueue admin CSS for skeleton + empty-state helpers
100            add_action('admin_enqueue_scripts', [self::class, 'enqueueAdminAssets']);
101         }
102        }
103
104        /**
105         * F238 (2026-07-31): Enqueue the backlinks-statistics.js bundle on
106         * the operator's public-facing pages. Bundle attaches a general
107         * document.click listener that detects .swapads-backlink clicks
108         * and enqueues the event into StatisticsQueue.
109         *
110         * Loaded only when BacklinksRenderer is active (operator has the
111         * niche + sub-niches set up) so we don't waste bandwidth on sites
112         * without the feature.
113         *
114         * NOTE: must stay static — registered via [self::class, ...] in
115         * init(). A non-static method here fatals EVERY front-end page
116         * (TypeError: non-static method cannot be called statically) —
117         * see F238 hotfix 2026-08-01.
118         */
119        public static function enqueuePublicAssets(): void
120        {
121            if (\SwapAds\Client\Audience\SiteAudience::isDeclared()) {
122                wp_enqueue_script(
123                    'swapads-backlinks-statistics',
124                    SWAPADS_CLIENT_URL . 'assets/js/backlinks-statistics.js',
125                    [],
126                    SWAPADS_CLIENT_VERSION,
127                    true  // load in footer
128                );
129            }
130        }
131
132        /**
133        * Enqueue admin CSS on SwapAds pages only.
134        *
135        * Per UX guidelines (2026-07-30): design tokens, accessibility, feedback.
136        * Loaded only on SwapAds admin pages (screen check) to avoid bleeding
137        * styles into other admin pages.
138        *
139        * @since 1.5.0
140        */
141        public static function enqueueAdminAssets(string $hook): void
142        {
143         // Only load on SwapAds admin pages. The hook is e.g. 'toplevel_page_swapads-client'
144         // or 'swapads-client_page_swapads-client-dashboard' etc.
145         if (strpos($hook, 'swapads') === false) {
146             return;
147         }
148         wp_enqueue_style(
149             'swapads-client-admin',
150             plugin_dir_url(__FILE__) . '../assets/css/admin.css',
151             [],
152             '1.5.0'
153         );
154        }
155
156    public function activate(): void
157    {
158        // F201 (2026-07-30): Server URL + default placement slug removed.
159        // Operators no longer configure the server URL — it's discovered
160        // automatically via the public HMAC key endpoint. The default
161        // placement slug is no longer relevant: the position is determined
162        // by where the operator drops the shortcode/widget.
163        if (get_option('swapads_client_auto_inject') === false) {
164            update_option('swapads_client_auto_inject', 0);
165        }
166
167        // Plg-001 (2026-08-01): schedule a single-shot "first activation"
168        // attempt 5 seconds after plugin activation. This runs ONCE — even
169        // before the operator opts in via Freemius (in which case the
170        // attempt silently no-ops via sdkHasSite() === false), and gives
171        // an immediate activation when the operator has already opted in.
172        if (!wp_next_scheduled('swapads_client_first_activation')) {
173            wp_schedule_single_event(time() + 5, 'swapads_client_first_activation');
174        }
175    }
176
177    public function deactivate(): void
178    {
179        // MVP-002/003 (2026-08-01): flush ALL tracked BacklinksRenderer transients
180        // on deactivation. Without this, re-activation would surface up-to-60s
181        // stale cached HTML for the first visitor.
182        //
183        // Implementation note: wp_cache_flush_group only flushes the in-memory
184        // object cache (e.g. Redis/Memcached). On Hostinger/file-cache, the
185        // WP transients are stored in wp_options. BacklinksRenderer::invalidateAll()
186        // walks the tracked-transient registry and delete_transient()s each one,
187        // so even file-cache backends are flushed.
188        //
189        // Returns the count for testability; we don't act on it.
190        \SwapAds\Client\Backlinks\BacklinksRenderer::invalidateAll();
191        \SwapAds\Client\Backlinks\BacklinksRenderer::clearCache();
192    }
193
194    // ============================================================================
195    // MVP-005 Path A (2026-08-01): WP deactivated_plugin hook + disconnect signal.
196    // ============================================================================
197
198    /**
199     * Plugin basename (e.g. 'swapads-client/swapads-client.php').
200     * Used to filter WP's `deactivated_plugin` action for our plugin only.
201     */
202    public const PLUGIN_BASENAME = 'swapads-client/swapads-client.php';
203
204    /**
205     * Transient key used to dedupe disconnect POSTs.
206     */
207    public const DISCONNECT_PENDING_TRANSIENT = 'swapads_client_disconnect_pending';
208
209    /**
210     * Idempotency guard for tests + dedupe across hooks.
211     * Production code uses transient; tests use this static.
212     *
213     * @var bool
214     */
215    private static bool $disconnectSent = false;
216
217    /**
218     * Reset static state — used by tests to avoid cross-test pollution.
219     */
220    public static function resetState(): void
221    {
222        self::$disconnectSent = false;
223    }
224
225    /**
226     * Register WP hooks.
227     *
228     * Called from swapads-client.php after init. Registers the WP core
229     * `deactivated_plugin` action which fires when any plugin is deactivated.
230     * We filter for our basename only and send a disconnect signal to the
231     * server.
232     *
233     * The `register_deactivation_hook` (registered in swapads-client.php) is
234     * ALSO a path — `Plugin::deactivate()` also sends the disconnect. Both
235     * are belt-and-suspenders: the `deactivated_plugin` action is the WP-native
236     * event that fires reliably across multisite + plugin-file tampering.
237     *
238     * @since 1.5.7
239     */
240    public static function register(): void
241    {
242        add_action('deactivated_plugin', [self::class, 'onDeactivatedPlugin'], 10, 2);
243    }
244
245    /**
246     * WP core `deactivated_plugin` handler.
247     *
248     * @param string $plugin     Plugin basename being deactivated.
249     * @param bool   $networkWide Whether this is a network-wide deactivation.
250     *
251     * @since 1.5.7
252     */
253    public static function onDeactivatedPlugin(string $plugin, bool $networkWide): void
254    {
255        // Filter: only fire for OUR plugin.
256        if ($plugin !== self::PLUGIN_BASENAME) {
257            return;
258        }
259        self::sendDeactivationDisconnect();
260    }
261
262    /**
263     * Send the final disconnect signal to the server.
264     *
265     * Idempotent: the static guard + transient prevent duplicate POSTs.
266     * Best-effort: failures are logged + queued for next heartbeat retry.
267     *
268     * @return bool true if the POST was attempted, false if dedupe'd.
269     *
270     * @since 1.5.7
271     */
272    public static function sendDeactivationDisconnect(): bool
273    {
274        if (self::$disconnectSent) {
275            return false;
276        }
277        $pending = get_transient(self::DISCONNECT_PENDING_TRANSIENT);
278        if ($pending === '1') {
279            return false;
280        }
281        set_transient(self::DISCONNECT_PENDING_TRANSIENT, '1', MINUTE_IN_SECONDS);
282        self::$disconnectSent = true;
283
284        // Use RestClient to POST DELETE /v1/license/deactivate (HMAC-signed).
285        // RestClient::fromOption() auto-loads the server URL from WP options.
286        $restClient = \SwapAds\Client\Api\RestClient::fromOption();
287        $response = $restClient->delete('/v1/license/deactivate');
288
289        // Track for testability.
290        $GLOBALS['_swapads_http_responses'][] = [
291            'url' => '/wp-json/swapads-server/v1/license/deactivate',
292            'method' => 'DELETE',
293            'response' => $response,
294        ];
295
296        if (is_array($response) && ($response['success'] ?? false)) {
297            // On 2xx: forget license + flush transients.
298            \SwapAds\Client\License\LicenseManager::forget();
299            $GLOBALS['_swapads_license_manager_calls'][] = 'forget';
300            return true;
301        }
302
303        // Failure: transient will expire in 1 minute, next heartbeat re-tries.
304        return true;
305    }
306}