Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.46% covered (success)
93.46%
100 / 107
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
BacklinksRenderer
93.46% covered (success)
93.46%
100 / 107
72.73% covered (warning)
72.73%
8 / 11
46.59
0.00% covered (danger)
0.00%
0 / 1
 render
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
6.01
 clearCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 trackTransient
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 invalidateAll
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 resetTracking
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 cacheKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 fetchApproved
84.85% covered (warning)
84.85%
28 / 33
0.00% covered (danger)
0.00%
0 / 1
11.42
 renderBacklinks
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 renderOne
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
9
 clampCount
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 normalizeMode
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * SwapAds Client — BacklinksRenderer (F236).
4 *
5 * Single source of truth for rendering approved (received) backlinks
6 * on the operator's site via the shortcode OR the Gutenberg block.
7 * Both surfaces (BacklinksShortcode + BacklinksBlock) call into this
8 * renderer so output stays in lockstep.
9 *
10 * Display modes:
11 *   - list (default): one link per row, with host suffix in parens
12 *   - card:           boxed layout, anchor + host as a separate line
13 *   - text:           inline anchors separated by spaces (for footers)
14 *
15 * Server data:
16 *   - GET /v1/backlinks/approved (HMAC-signed)
17 *   - Returns { backlinks: [{url, anchor_text, rel_attribute}, ...], count }
18 *   - Filters: audience (string), category (string, substring of host), random (bool)
19 *
20 * @since 1.5.3
21 */
22
23declare(strict_types=1);
24
25namespace SwapAds\Client\Backlinks;
26
27use SwapAds\Client\Api\RestClient;
28use SwapAds\Client\License\LicenseManager;
29
30final class BacklinksRenderer
31{
32    public const MODE_LIST = 'list';
33    public const MODE_CARD = 'card';
34    public const MODE_TEXT = 'text';
35
36    public const ALLOWED_MODES = [self::MODE_LIST, self::MODE_CARD, self::MODE_TEXT];
37
38    public const DEFAULT_COUNT = 5;
39    public const MIN_COUNT     = 1;
40    public const MAX_COUNT     = 50;
41
42    public const CACHE_GROUP = 'swapads_backlinks_render';
43    public const CACHE_TTL   = 60; // MVP-002/003 (2026-08-01): tightened from 300s. Operator-displayed but backlink-retraction propagation must surface quickly (per journey §4.3).
44
45    /**
46     * Render the full HTML output for an operator's approved-backlinks
47     * block/shortcode. Falls back to empty string (NOT a warning) when
48     * the site is unlicensed or the server has nothing for them — the
49     * Gutenberg block reads as a placeholder in the editor, visitor-
50     * facing pages render a no-op (avoids 401s reaching the front end).
51     *
52     * @param array<string, mixed> $args {
53     *   @type int    $count     1-50, default 5
54     *   @type string $mode      list|card|text, default list
55     *   @type string $audience  filter by audience code, optional
56     *   @type string $category  filter by host substring, optional
57     *   @type bool   $random    shuffle order, default false (newest first)
58     *   @type bool   $cache     use transients, default true
59     * }
60     *
61     * @return string Rendered HTML, empty string on no-data.
62     */
63    public static function render(array $args = []): string
64    {
65        $count    = self::clampCount((int) ($args['count'] ?? self::DEFAULT_COUNT));
66        $mode     = self::normalizeMode((string) ($args['mode'] ?? self::MODE_LIST));
67        $audience = trim((string) ($args['audience'] ?? ''));
68        $category = trim((string) ($args['category'] ?? ''));
69        $random   = (bool) ($args['random'] ?? false);
70        $cache    = (bool) ($args['cache'] ?? true);
71
72        $cacheKey = self::cacheKey($count, $mode, $audience, $category, $random);
73        if ($cache) {
74            $cached = get_transient($cacheKey);
75            if (is_string($cached)) {
76                return $cached;
77            }
78        }
79
80        $backlinks = self::fetchApproved($count, $audience, $category, $random);
81        if (count($backlinks) === 0) {
82            $html = '';
83        } else {
84            $html = self::renderBacklinks($backlinks, $mode);
85        }
86
87        if ($cache && $html !== '') {
88            set_transient($cacheKey, $html, self::CACHE_TTL);
89            // MVP-002/003 (2026-08-01): track every written transient key so
90            // Plugin::deactivate() can flush the transient table on Hostinger
91            // (wp_cache_flush_group only flushes the in-memory object cache,
92            // not the wp_options-stored transients).
93            self::trackTransient($cacheKey);
94        }
95
96        return $html;
97    }
98
99    /**
100     * Clear the per-(count,mode,audience,category,random) cache.
101     * Called when the operator mutates their backlink set
102     * (approve, reject, source-check status change).
103     */
104    public static function clearCache(): void
105    {
106        // F236: best-effort flush. WP transients don't have a
107        // pattern-delete primitive, so we rely on TTL=60 to expire.
108        // Operators see at most 1 min staleness after a change.
109        wp_cache_flush_group(self::CACHE_GROUP);
110        // MVP-002/003 (2026-08-01): also delete every tracked transient key
111        // (covers the wp_options-stored transients on Hostinger).
112        self::invalidateAll();
113    }
114
115    /**
116     * Track a transient key written by this renderer so it can be
117     * invalidated on demand. Stores the key in the $GLOBALS registry.
118     *
119     * MVP-002/003 (2026-08-01): needed because WP's wp_cache_flush_group
120     * does NOT flush wp_options-stored transients (it only flushes the
121     * in-memory object cache). For Hostinger/file-cache, the
122     * invalidateAll() sweep is the only way to flush within a request
123     * without waiting for TTL.
124     *
125     * @param string $key Transient key (full cache key, already hashed).
126     */
127    public static function trackTransient(string $key): void
128    {
129        if (!isset($GLOBALS['_swapads_backlinks_transient_keys']) || !is_array($GLOBALS['_swapads_backlinks_transient_keys'])) {
130            $GLOBALS['_swapads_backlinks_transient_keys'] = [];
131        }
132        $GLOBALS['_swapads_backlinks_transient_keys'][$key] = true;
133    }
134
135    /**
136     * Delete every tracked transient key.
137     *
138     * MVP-002/003 (2026-08-01): called from Plugin::deactivate() so the
139     * next request (after re-activation) sees fresh data, not stale cached
140     * HTML from the previous site lifecycle. Returns the count of deleted
141     * keys for testability.
142     *
143     * @return int Number of transient keys deleted.
144     */
145    public static function invalidateAll(): int
146    {
147        $deleted = 0;
148        $keys = $GLOBALS['_swapads_backlinks_transient_keys'] ?? [];
149        if (is_array($keys)) {
150            foreach (array_keys($keys) as $key) {
151                if (is_string($key) && delete_transient($key)) {
152                    $deleted++;
153                }
154            }
155            // Reset the registry after a full sweep.
156            $GLOBALS['_swapads_backlinks_transient_keys'] = [];
157        }
158        return $deleted;
159    }
160
161    /**
162     * Test-only: clear the tracked-transients registry without deleting
163     * the underlying transients. Lets tests reset between cases.
164     */
165    public static function resetTracking(): void
166    {
167        $GLOBALS['_swapads_backlinks_transient_keys'] = [];
168    }
169
170    /**
171     * Build the cache key. Stable across requests for the same
172     * operator + filter combination.
173     */
174    private static function cacheKey(int $count, string $mode, string $audience, string $category, bool $random): string
175    {
176        $license = (string) LicenseManager::key();
177        $site    = (string) (function_exists('home_url') ? home_url() : '');
178        $hash    = substr(md5($count . '|' . $mode . '|' . $audience . '|' . $category . '|' . ($random ? '1' : '0')), 0, 12);
179        return self::CACHE_GROUP . '_' . substr(md5($license . '|' . $site), 0, 12) . '_' . $hash;
180    }
181
182    /**
183     * Pull approved backlinks from the server's REST endpoint.
184     * Returns [] on transport/auth failure (the operator sees nothing
185     * rather than a 500 on the front end).
186     *
187     * @return array<int, array{id: int, url: string, anchor_text: string, rel_attribute: string}>
188     */
189    private static function fetchApproved(int $count, string $audience, string $category, bool $random): array
190    {
191        $licenseKey = (string) LicenseManager::key();
192        if ($licenseKey === '') {
193            return [];
194        }
195
196        $queryArgs = [
197            'count'  => $count,
198            'random' => $random ? '1' : '0',
199        ];
200        if ($audience !== '') {
201            $queryArgs['audience'] = $audience;
202        }
203        if ($category !== '') {
204            $queryArgs['category'] = $category;
205        }
206
207        $client = RestClient::fromOption();
208        $response = $client->get('/backlinks/approved', $queryArgs);
209        if (!is_array($response)) {
210            return [];
211        }
212        $backlinks = $response['backlinks'] ?? null;
213        if (!is_array($backlinks)) {
214            return [];
215        }
216
217        $normalized = [];
218        foreach ($backlinks as $row) {
219            if (!is_array($row)) {
220                continue;
221            }
222            $url    = (string) ($row['url'] ?? '');
223            $anchor = (string) ($row['anchor_text'] ?? '');
224            if ($url === '' || $anchor === '') {
225                continue;
226            }
227            $normalized[] = [
228                'id'            => (int) ($row['id'] ?? 0),
229                'url'           => $url,
230                'anchor_text'   => $anchor,
231                'rel_attribute' => (string) ($row['rel_attribute'] ?? 'dofollow'),
232            ];
233        }
234        return $normalized;
235    }
236
237    /**
238     * Render the inner HTML for the given backlinks + mode.
239     * The outer `<div class="swapads-backlinks swapads-backlinks-{$mode}">`
240     * is always present so CSS targeting works consistently.
241     *
242     * @param array<int, array{id: int, url: string, anchor_text: string, rel_attribute: string}> $backlinks
243     */
244    private static function renderBacklinks(array $backlinks, string $mode): string
245    {
246        $out = '<div class="swapads-backlinks swapads-backlinks-' . esc_attr($mode) . '">';
247        foreach ($backlinks as $link) {
248            $url    = (string) $link['url'];
249            $anchor = (string) $link['anchor_text'];
250            $rel    = (string) $link['rel_attribute'];
251            $host   = (string) wp_parse_url($url, PHP_URL_HOST);
252
253            $id = (int) $link['id'];
254            $out .= self::renderOne($id, $url, $anchor, $rel, $host, $mode);
255        }
256        $out .= '</div>';
257        return $out;
258    }
259
260    /**
261     * Render a single backlink in the requested mode.
262     * Extracted so the test suite can assert per-row markup without
263     * setting up the full wrapper.
264     */
265    private static function renderOne(int $id, string $url, string $anchor, string $rel, string $host, string $mode): string
266    {
267        $safeUrl    = esc_url($url);
268        $safeAnchor = esc_html($anchor);
269        $safeRel    = esc_attr($rel);
270        $safeHost   = $host !== '' ? esc_html($host) : '';
271        // F238: backlink_id is what the JS click handler reads via data-swapads-sid
272        // to enqueue click events. Without this, F238 statistics don't track.
273        $dataAttr   = $id > 0 ? ' data-swapads-sid="' . esc_attr((string) $id) . '"' : '';
274
275        switch ($mode) {
276            case self::MODE_CARD:
277                $html  = '<div class="swapads-backlink-card">';
278                $html .= '<a class="swapads-backlink" href="' . $safeUrl . '" rel="' . $safeRel . '" target="_blank"' . $dataAttr . '>' . $safeAnchor . '</a>';
279                if ($safeHost !== '') {
280                    $html .= '<span class="swapads-backlink-host">' . $safeHost . '</span>';
281                }
282                $html .= '</div>';
283                return $html;
284
285            case self::MODE_TEXT:
286                return '<a class="swapads-backlink swapads-backlink-inline" href="' . $safeUrl . '" rel="' . $safeRel . '" target="_blank"' . $dataAttr . '>' . $safeAnchor . '</a> ';
287
288            case self::MODE_LIST:
289            default:
290                $html  = '<div class="swapads-backlink-item">';
291                $html .= '<a class="swapads-backlink" href="' . $safeUrl . '" rel="' . $safeRel . '" target="_blank"' . $dataAttr . '>' . $safeAnchor . '</a>';
292                if ($safeHost !== '') {
293                    $html .= ' <span class="swapads-backlink-host">(' . $safeHost . ')</span>';
294                }
295                $html .= '</div>';
296                return $html;
297        }
298    }
299
300    /**
301     * Clamp the requested count to the allowed range.
302     */
303    public static function clampCount(int $count): int
304    {
305        if ($count < self::MIN_COUNT) {
306            return self::MIN_COUNT;
307        }
308        if ($count > self::MAX_COUNT) {
309            return self::MAX_COUNT;
310        }
311        return $count;
312    }
313
314    /**
315     * Normalize the mode to one of the allowed values. Defaults to
316     * MODE_LIST when the input is unknown.
317     */
318    public static function normalizeMode(string $mode): string
319    {
320        $mode = strtolower(trim($mode));
321        if (in_array($mode, self::ALLOWED_MODES, true)) {
322            return $mode;
323        }
324        return self::MODE_LIST;
325    }
326}