Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
28.14% covered (danger)
28.14%
65 / 231
15.38% covered (danger)
15.38%
2 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
SourceCheckPage
28.14% covered (danger)
28.14%
65 / 231
15.38% covered (danger)
15.38%
2 / 13
796.47
0.00% covered (danger)
0.00%
0 / 1
 register
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addMenu
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 render
45.65% covered (danger)
45.65%
21 / 46
0.00% covered (danger)
0.00%
0 / 1
14.87
 fetchSourceCheckStatus
36.84% covered (danger)
36.84%
7 / 19
0.00% covered (danger)
0.00%
0 / 1
11.30
 renderSummaryCard
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
42
 renderTable
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
30
 renderRecheckForm
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 renderRecheckAllForm
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 handleRecheck
69.77% covered (warning)
69.77%
30 / 43
0.00% covered (danger)
0.00%
0 / 1
15.98
 redirectWithError
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 renderStatusBadge
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 renderResultBadge
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 renderMetricTile
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Source Check Admin Page (F117).
4 *
5 * Operator-facing UI for the daily source-URL check.
6 *
7 * Per BACKLINK-SYSTEM-ARCHITECTURE §6.4: server daily GET request to
8 * each offer's source URL. On 404 / no_content / error → mark offer
9 * paused + refund all approvers (handled by SourceCheckService).
10 *
11 * This page:
12 *   - Lists all of the operator's backlink offers with their
13 *     source-check status (last_checked_at, last_source_check_status).
14 *   - Highlights offers that are overdue (>20h since last check).
15 *   - Highlights offers whose status is not "ok" (paused, 404, etc).
16 *   - Lets the operator manually trigger a recheck on individual offers
17 *     or all active offers in one click.
18 *
19 * Errors from the API are surfaced inline via ClientErrorRenderer
20 * (F198 pattern).
21 *
22 * @package SwapAds\Client\Admin
23 * @since   1.5.0
24 */
25
26declare(strict_types=1);
27
28namespace SwapAds\Client\Admin;
29
30use SwapAds\Client\Admin\OperatorDashboardHubPage;
31use SwapAds\Client\Api\RestClient;
32use SwapAds\Client\License\LicenseManager;
33
34/**
35 * Class SourceCheckPage.
36 *
37 * @since 1.5.0
38 */
39final class SourceCheckPage
40{
41    public const MENU_SLUG    = 'swapads-client-source-check';
42    public const NONCE_ACTION = 'swapads_client_source_check';
43    public const ERROR_CONTEXT = 'swapads_client_source_check';
44    public const RECHECK_BATCH_LIMIT = 50;
45
46    /**
47     * Register WP hooks.
48     */
49    public static function register(): void
50    {
51        add_action('admin_menu', [self::class, 'addMenu']);
52        add_action('admin_post_swapads_client_source_check_recheck', [self::class, 'handleRecheck']);
53    }
54
55    /**
56     * Register the admin page (sub-menu of SwapAds).
57     */
58    public static function addMenu(): void
59    {
60        add_submenu_page(
61            OperatorDashboardHubPage::MENU_SLUG,
62            'Source Check Status',
63            'Source Check',
64            'manage_options',
65            self::MENU_SLUG,
66            [self::class, 'render']
67        );
68    }
69
70    /**
71     * Render the source-check admin page.
72     */
73    public static function render(): void
74    {
75        if (!current_user_can('manage_options')) {
76            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
77        }
78
79        echo Renderer::pageHeader(
80            'Source Check Status',
81            'Each of your backlink offers is checked daily. Offfers that return 404 or no content are auto-paused and approvers are refunded.'
82        );
83
84        // F198: surface any persisted error from previous recheck attempt.
85        // F2XX (2026-08-01): also surface the success-summary transient that
86        // handleRecheck() writes on a successful run (was getting rendered as
87        // a red "failed" banner before this fix).
88        $persisted = get_transient(self::ERROR_CONTEXT);
89        if (is_array($persisted)) {
90            echo ClientErrorRenderer::render(self::ERROR_CONTEXT, $persisted, 'recheck source URLs');
91            delete_transient(self::ERROR_CONTEXT);
92        }
93        $summary = get_transient('swapads_source_check_summary');
94        if (is_array($summary)) {
95            echo ClientErrorRenderer::render('swapads_source_check_summary', $summary, 'recheck source URLs');
96            delete_transient('swapads_source_check_summary');
97        }
98
99        if (!LicenseManager::isLicensed()) {
100            echo Renderer::card(
101                'License required',
102                '<p>Activate your license before viewing source-check status.</p>',
103                'swapads-card-warning'
104            );
105            echo Renderer::pageFooter();
106            return;
107        }
108
109        $data = self::fetchSourceCheckStatus();
110        if (!is_array($data)) {
111            echo Renderer::card(
112                'Source check unavailable',
113                '<p>Could not fetch source-check data from the server. See the notice above for the exact response.</p>',
114                'swapads-card-error'
115            );
116            echo Renderer::pageFooter();
117            return;
118        }
119
120        $items = $data['items'] ?? [];
121        $totalCount = (int) ($data['count'] ?? count($items));
122        echo self::renderSummaryCard($items, $totalCount);
123
124        if (count($items) === 0) {
125            echo Renderer::card(
126                'No backlink offers yet',
127                '<p>Once you create your first backlink offer, it will appear here with daily source-check status.</p>',
128                'swapads-card-info'
129            );
130            echo self::renderRecheckAllForm();
131            echo Renderer::pageFooter();
132            return;
133        }
134
135        echo self::renderTable($items);
136        echo self::renderRecheckAllForm();
137        echo Renderer::pageFooter();
138    }
139
140    /**
141     * Fetch source-check status from server.
142     *
143     * F198: RestClient::request() never throws for REST/transport
144     * failures — lastError() captures the envelope and we persist it
145     * for render() to surface inline.
146     *
147     * @return array<string, mixed>|null Null on error (error persisted to transient).
148     */
149    public static function fetchSourceCheckStatus(): ?array
150    {
151        $client = RestClient::fromOption();
152        try {
153            $response = $client->get('/v1/backlinks/source-check');
154        } catch (\Throwable $e) {
155            set_transient(self::ERROR_CONTEXT, [
156                'error_code'  => 'FATAL',
157                'message'     => 'Unexpected fatal error: ' . $e->getMessage(),
158                'http_status' => 0,
159                'when'        => time(),
160            ], MINUTE_IN_SECONDS);
161            return null;
162        }
163        if (!is_array($response)) {
164            if (($err = $client->lastError()) !== null) {
165                set_transient(self::ERROR_CONTEXT, $err, MINUTE_IN_SECONDS);
166            }
167            return null;
168        }
169        if (empty($response['success'])) {
170            $err = $client->lastError() ?? $response;
171            set_transient(self::ERROR_CONTEXT, $err, MINUTE_IN_SECONDS);
172            return null;
173        }
174        return $response;
175    }
176
177    /**
178     * Render the summary card (counts by status).
179     *
180     * @param array<int, array<string, mixed>> $items
181     */
182    private static function renderSummaryCard(array $items, int $totalCount): string
183    {
184        $ok = 0;
185        $paused = 0;
186        $overdue = 0;
187        $other = 0;
188        $overdueThresholdHours = 20;
189        foreach ($items as $it) {
190            $status = (string) ($it['status'] ?? '');
191            $age    = $it['last_check_age_hours'];
192            if ($status === 'active') {
193                $ok++;
194            } elseif ($status === 'paused') {
195                $paused++;
196            } else {
197                $other++;
198            }
199            if ($age !== null && (int) $age > $overdueThresholdHours) {
200                $overdue++;
201            }
202        }
203        $html  = '<div class="swapads-card">';
204        $html .= '<h2>Summary</h2>';
205        $html .= '<div class="swapads-metric-grid">';
206        $html .= self::renderMetricTile('Total offers',     $totalCount, 'all your backlink offers');
207        $html .= self::renderMetricTile('Active',           $ok,         'currently in rotation');
208        $html .= self::renderMetricTile('Auto-paused',      $paused,     'paused by source check');
209        $html .= self::renderMetricTile('Other status',     $other,      'depleted, removed, etc.');
210        $html .= self::renderMetricTile('Overdue check',    $overdue,    'not checked in >20h');
211        $html .= '</div></div>';
212        return $html;
213    }
214
215    /**
216     * Render the per-offer source-check table.
217     *
218     * @param array<int, array<string, mixed>> $items
219     */
220    private static function renderTable(array $items): string
221    {
222        $html  = '<div class="swapads-card">';
223        $html .= '<h2>Per-Offer Source-Check Status</h2>';
224        $html .= '<table class="widefat striped" style="margin-top:8px;">';
225        $html .= '<thead><tr>';
226        $html .= '<th style="width:60px;">ID</th>';
227        $html .= '<th>Source URL</th>';
228        $html .= '<th style="width:100px;">Status</th>';
229        $html .= '<th style="width:160px;">Last Checked</th>';
230        $html .= '<th style="width:160px;">Check Result</th>';
231        $html .= '<th style="width:120px;">Action</th>';
232        $html .= '</tr></thead><tbody>';
233
234        foreach ($items as $it) {
235            $id = (int) ($it['id'] ?? 0);
236            $sourceUrl = (string) ($it['source_url'] ?? '');
237            $status = (string) ($it['status'] ?? '');
238            $lastCheckedAt = $it['last_checked_at'] ?? null;
239            $lastStatus = (string) ($it['last_source_check_status'] ?? '');
240            $ageHours = $it['last_check_age_hours'];
241            $ageLabel = $ageHours === null ? 'never' : ((int) $ageHours) . 'h ago';
242
243            $statusBadge = self::renderStatusBadge($status);
244            $resultBadge = self::renderResultBadge($lastStatus);
245
246            $html .= '<tr>';
247            $html .= '<td>' . esc_html((string) $id) . '</td>';
248            $html .= '<td><code style="font-size:11px;">' . esc_html($sourceUrl) . '</code></td>';
249            $html .= '<td>' . $statusBadge . '</td>';
250            $html .= '<td>' . esc_html($ageLabel) . ($lastCheckedAt !== null ? '<br><small>' . esc_html((string) $lastCheckedAt) . '</small>' : '') . '</td>';
251            $html .= '<td>' . $resultBadge . '</td>';
252            $html .= '<td>';
253            if ($id > 0) {
254                $html .= self::renderRecheckForm($id);
255            }
256            $html .= '</td>';
257            $html .= '</tr>';
258        }
259
260        $html .= '</tbody></table></div>';
261        return $html;
262    }
263
264    /**
265     * Render the recheck form for a single backlink.
266     */
267    private static function renderRecheckForm(int $backlinkId): string
268    {
269        $action = esc_url(admin_url('admin-post.php'));
270        $nonce  = wp_create_nonce(self::NONCE_ACTION);
271        return sprintf(
272            '<form method="post" action="%s" style="display:inline-block;">
273                <input type="hidden" name="action" value="swapads_client_source_check_recheck">
274                <input type="hidden" name="_wpnonce" value="%s">
275                <input type="hidden" name="backlink_id" value="%d">
276                <button type="submit" class="button button-small">Recheck</button>
277            </form>',
278            $action,
279            esc_attr($nonce),
280            $backlinkId
281        );
282    }
283
284    /**
285     * Render the "recheck all active" form.
286     */
287    private static function renderRecheckAllForm(): string
288    {
289        $action = esc_url(admin_url('admin-post.php'));
290        $nonce  = wp_create_nonce(self::NONCE_ACTION);
291        return sprintf(
292            '<div class="swapads-card" style="margin-top:16px;">
293                <h2>Manual Recheck</h2>
294                <p>Force a recheck of all your active offers right now (in addition to the daily 03:00 UTC cron run).</p>
295                <form method="post" action="%s">
296                    <input type="hidden" name="action" value="swapads_client_source_check_recheck">
297                    <input type="hidden" name="_wpnonce" value="%s">
298                    <input type="hidden" name="all" value="1">
299                    <button type="submit" class="button button-primary">Recheck all active offers</button>
300                </form>
301            </div>',
302            $action,
303            esc_attr($nonce)
304        );
305    }
306
307    /**
308     * Handle the recheck form POST.
309     */
310    public static function handleRecheck(): void
311    {
312        if (!current_user_can('manage_options')) {
313            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
314        }
315        check_admin_referer(self::NONCE_ACTION);
316
317        $all = (string) ($_POST['all'] ?? '') === '1';
318        $backlinkId = (int) ($_POST['backlink_id'] ?? 0);
319
320        $client = RestClient::fromOption();
321        try {
322            if ($all) {
323                $response = $client->post('/v1/backlinks/source-check/recheck', ['all' => true]);
324            } elseif ($backlinkId > 0) {
325                $response = $client->post('/v1/backlinks/source-check/recheck', ['backlink_ids' => [$backlinkId]]);
326            } else {
327                self::redirectWithError('Either all=1 or backlink_id required');
328                return;
329            }
330        } catch (\Throwable $e) {
331            set_transient(self::ERROR_CONTEXT, [
332                'error_code'  => 'FATAL',
333                'message'     => 'Unexpected fatal error: ' . $e->getMessage(),
334                'http_status' => 0,
335                'when'        => time(),
336            ], MINUTE_IN_SECONDS);
337            self::redirectWithError($e->getMessage());
338            return;
339        }
340
341        if (is_array($response) && empty($response['success'])) {
342            $err = $client->lastError() ?? $response;
343            set_transient(self::ERROR_CONTEXT, $err, MINUTE_IN_SECONDS);
344            self::redirectWithError((string) ($response['error_code'] ?? 'unknown'));
345            return;
346        }
347
348        if (is_array($response) && !empty($response['success'])) {
349            $results = $response['results'] ?? [];
350            $ok = 0;
351            $bad = 0;
352            $refunds = 0;
353            foreach ($results as $r) {
354                $res = (string) ($r['result'] ?? '');
355                if ($res === 'ok') { $ok++; } else { $bad++; }
356                $refunds += (int) ($r['refunds'] ?? 0);
357            }
358            // F2XX (2026-08-01): use a SEPARATE transient for success + non-error
359            // messaging. ClientErrorRenderer renders RECHECK_SUMMARY as a green
360            // success notice (was red "failed" banner before this fix).
361            set_transient('swapads_source_check_summary', [
362                'error_code'  => 'RECHECK_SUMMARY',
363                'message'     => sprintf('Recheck complete: %d ok, %d issues, %d refunds.', $ok, $bad, $refunds),
364                'http_status' => 200,
365                'when'        => time(),
366            ], MINUTE_IN_SECONDS);
367        }
368
369        wp_safe_redirect(add_query_arg(['page' => self::MENU_SLUG, 'rechecked' => '1'], admin_url('admin.php')));
370        if (!defined('SWAPADS_TESTING_REDIRECT_EXIT')) { exit; }
371    }
372
373    /**
374     * Redirect back with an error code.
375     */
376    private static function redirectWithError(string $errorCode): void
377    {
378        wp_safe_redirect(add_query_arg(
379            ['page' => self::MENU_SLUG, 'error' => rawurlencode($errorCode)],
380            admin_url('admin.php')
381        ));
382        if (!defined('SWAPADS_TESTING_REDIRECT_EXIT')) { exit; }
383    }
384
385    /**
386     * Render a status badge (active/paused/depleted).
387     */
388    private static function renderStatusBadge(string $status): string
389    {
390        $colors = [
391            'active'   => '#46b450',
392            'paused'   => '#d63638',
393            'depleted' => '#996633',
394            'removed'  => '#666',
395        ];
396        $color = $colors[$status] ?? '#666';
397        return sprintf(
398            '<span style="display:inline-block;padding:2px 8px;border-radius:3px;background:%s;color:#fff;font-size:11px;font-weight:600;">%s</span>',
399            esc_attr($color),
400            esc_html($status)
401        );
402    }
403
404    /**
405     * Render a check-result badge (ok/404/no_content/error).
406     */
407    private static function renderResultBadge(string $result): string
408    {
409        if ($result === '') {
410            return '<span style="color:#666;">—</span>';
411        }
412        $colors = [
413            'ok'         => '#46b450',
414            '404'        => '#d63638',
415            'no_content' => '#dba617',
416            'error'      => '#d63638',
417        ];
418        $color = $colors[$result] ?? '#996633';
419        return sprintf(
420            '<span style="display:inline-block;padding:2px 8px;border-radius:3px;background:%s;color:#fff;font-size:11px;font-weight:600;">%s</span>',
421            esc_attr($color),
422            esc_html($result)
423        );
424    }
425
426    /**
427     * Render a metric tile (used in summary).
428     */
429    private static function renderMetricTile(string $label, int $value, string $hint): string
430    {
431        return sprintf(
432            '<div class="swapads-metric-tile"><div class="swapads-metric-value">%s</div><div class="swapads-metric-label">%s</div><div class="swapads-metric-hint">%s</div></div>',
433            esc_html((string) $value),
434            esc_html($label),
435            esc_html($hint)
436        );
437    }
438}