<?php
/**
 * SwapAds Client — BacklinksRenderer (F236).
 *
 * Single source of truth for rendering approved (received) backlinks
 * on the operator's site via the shortcode OR the Gutenberg block.
 * Both surfaces (BacklinksShortcode + BacklinksBlock) call into this
 * renderer so output stays in lockstep.
 *
 * Display modes:
 *   - list (default): one link per row, with host suffix in parens
 *   - card:           boxed layout, anchor + host as a separate line
 *   - text:           inline anchors separated by spaces (for footers)
 *
 * Server data:
 *   - GET /v1/backlinks/approved (HMAC-signed)
 *   - Returns { backlinks: [{url, anchor_text, rel_attribute}, ...], count }
 *   - Filters: audience (string), category (string, substring of host), random (bool)
 *
 * @since 1.5.3
 */

declare(strict_types=1);

namespace SwapAds\Client\Backlinks;

use SwapAds\Client\Api\RestClient;
use SwapAds\Client\License\LicenseManager;

final class BacklinksRenderer
{
    public const MODE_LIST = 'list';
    public const MODE_CARD = 'card';
    public const MODE_TEXT = 'text';

    public const ALLOWED_MODES = [self::MODE_LIST, self::MODE_CARD, self::MODE_TEXT];

    public const DEFAULT_COUNT = 5;
    public const MIN_COUNT     = 1;
    public const MAX_COUNT     = 50;

    public const CACHE_GROUP = 'swapads_backlinks_render';
    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).

    /**
     * Render the full HTML output for an operator's approved-backlinks
     * block/shortcode. Falls back to empty string (NOT a warning) when
     * the site is unlicensed or the server has nothing for them — the
     * Gutenberg block reads as a placeholder in the editor, visitor-
     * facing pages render a no-op (avoids 401s reaching the front end).
     *
     * @param array<string, mixed> $args {
     *   @type int    $count     1-50, default 5
     *   @type string $mode      list|card|text, default list
     *   @type string $audience  filter by audience code, optional
     *   @type string $category  filter by host substring, optional
     *   @type bool   $random    shuffle order, default false (newest first)
     *   @type bool   $cache     use transients, default true
     * }
     *
     * @return string Rendered HTML, empty string on no-data.
     */
    public static function render(array $args = []): string
    {
        $count    = self::clampCount((int) ($args['count'] ?? self::DEFAULT_COUNT));
        $mode     = self::normalizeMode((string) ($args['mode'] ?? self::MODE_LIST));
        $audience = trim((string) ($args['audience'] ?? ''));
        $category = trim((string) ($args['category'] ?? ''));
        $random   = (bool) ($args['random'] ?? false);
        $cache    = (bool) ($args['cache'] ?? true);

        $cacheKey = self::cacheKey($count, $mode, $audience, $category, $random);
        if ($cache) {
            $cached = get_transient($cacheKey);
            if (is_string($cached)) {
                return $cached;
            }
        }

        $backlinks = self::fetchApproved($count, $audience, $category, $random);
        if (count($backlinks) === 0) {
            $html = '';
        } else {
            $html = self::renderBacklinks($backlinks, $mode);
        }

        if ($cache && $html !== '') {
            set_transient($cacheKey, $html, self::CACHE_TTL);
            // MVP-002/003 (2026-08-01): track every written transient key so
            // Plugin::deactivate() can flush the transient table on Hostinger
            // (wp_cache_flush_group only flushes the in-memory object cache,
            // not the wp_options-stored transients).
            self::trackTransient($cacheKey);
        }

        return $html;
    }

    /**
     * Clear the per-(count,mode,audience,category,random) cache.
     * Called when the operator mutates their backlink set
     * (approve, reject, source-check status change).
     */
    public static function clearCache(): void
    {
        // F236: best-effort flush. WP transients don't have a
        // pattern-delete primitive, so we rely on TTL=60 to expire.
        // Operators see at most 1 min staleness after a change.
        wp_cache_flush_group(self::CACHE_GROUP);
        // MVP-002/003 (2026-08-01): also delete every tracked transient key
        // (covers the wp_options-stored transients on Hostinger).
        self::invalidateAll();
    }

    /**
     * Track a transient key written by this renderer so it can be
     * invalidated on demand. Stores the key in the $GLOBALS registry.
     *
     * MVP-002/003 (2026-08-01): needed because WP's wp_cache_flush_group
     * does NOT flush wp_options-stored transients (it only flushes the
     * in-memory object cache). For Hostinger/file-cache, the
     * invalidateAll() sweep is the only way to flush within a request
     * without waiting for TTL.
     *
     * @param string $key Transient key (full cache key, already hashed).
     */
    public static function trackTransient(string $key): void
    {
        if (!isset($GLOBALS['_swapads_backlinks_transient_keys']) || !is_array($GLOBALS['_swapads_backlinks_transient_keys'])) {
            $GLOBALS['_swapads_backlinks_transient_keys'] = [];
        }
        $GLOBALS['_swapads_backlinks_transient_keys'][$key] = true;
    }

    /**
     * Delete every tracked transient key.
     *
     * MVP-002/003 (2026-08-01): called from Plugin::deactivate() so the
     * next request (after re-activation) sees fresh data, not stale cached
     * HTML from the previous site lifecycle. Returns the count of deleted
     * keys for testability.
     *
     * @return int Number of transient keys deleted.
     */
    public static function invalidateAll(): int
    {
        $deleted = 0;
        $keys = $GLOBALS['_swapads_backlinks_transient_keys'] ?? [];
        if (is_array($keys)) {
            foreach (array_keys($keys) as $key) {
                if (is_string($key) && delete_transient($key)) {
                    $deleted++;
                }
            }
            // Reset the registry after a full sweep.
            $GLOBALS['_swapads_backlinks_transient_keys'] = [];
        }
        return $deleted;
    }

    /**
     * Test-only: clear the tracked-transients registry without deleting
     * the underlying transients. Lets tests reset between cases.
     */
    public static function resetTracking(): void
    {
        $GLOBALS['_swapads_backlinks_transient_keys'] = [];
    }

    /**
     * Build the cache key. Stable across requests for the same
     * operator + filter combination.
     */
    private static function cacheKey(int $count, string $mode, string $audience, string $category, bool $random): string
    {
        $license = (string) LicenseManager::key();
        $site    = (string) (function_exists('home_url') ? home_url() : '');
        $hash    = substr(md5($count . '|' . $mode . '|' . $audience . '|' . $category . '|' . ($random ? '1' : '0')), 0, 12);
        return self::CACHE_GROUP . '_' . substr(md5($license . '|' . $site), 0, 12) . '_' . $hash;
    }

    /**
     * Pull approved backlinks from the server's REST endpoint.
     * Returns [] on transport/auth failure (the operator sees nothing
     * rather than a 500 on the front end).
     *
     * @return array<int, array{id: int, url: string, anchor_text: string, rel_attribute: string}>
     */
    private static function fetchApproved(int $count, string $audience, string $category, bool $random): array
    {
        $licenseKey = (string) LicenseManager::key();
        if ($licenseKey === '') {
            return [];
        }

        $queryArgs = [
            'count'  => $count,
            'random' => $random ? '1' : '0',
        ];
        if ($audience !== '') {
            $queryArgs['audience'] = $audience;
        }
        if ($category !== '') {
            $queryArgs['category'] = $category;
        }

        $client = RestClient::fromOption();
        $response = $client->get('/backlinks/approved', $queryArgs);
        if (!is_array($response)) {
            return [];
        }
        $backlinks = $response['backlinks'] ?? null;
        if (!is_array($backlinks)) {
            return [];
        }

        $normalized = [];
        foreach ($backlinks as $row) {
            if (!is_array($row)) {
                continue;
            }
            $url    = (string) ($row['url'] ?? '');
            $anchor = (string) ($row['anchor_text'] ?? '');
            if ($url === '' || $anchor === '') {
                continue;
            }
            $normalized[] = [
                'id'            => (int) ($row['id'] ?? 0),
                'url'           => $url,
                'anchor_text'   => $anchor,
                'rel_attribute' => (string) ($row['rel_attribute'] ?? 'dofollow'),
            ];
        }
        return $normalized;
    }

    /**
     * Render the inner HTML for the given backlinks + mode.
     * The outer `<div class="swapads-backlinks swapads-backlinks-{$mode}">`
     * is always present so CSS targeting works consistently.
     *
     * @param array<int, array{id: int, url: string, anchor_text: string, rel_attribute: string}> $backlinks
     */
    private static function renderBacklinks(array $backlinks, string $mode): string
    {
        $out = '<div class="swapads-backlinks swapads-backlinks-' . esc_attr($mode) . '">';
        foreach ($backlinks as $link) {
            $url    = (string) $link['url'];
            $anchor = (string) $link['anchor_text'];
            $rel    = (string) $link['rel_attribute'];
            $host   = (string) wp_parse_url($url, PHP_URL_HOST);

            $id = (int) $link['id'];
            $out .= self::renderOne($id, $url, $anchor, $rel, $host, $mode);
        }
        $out .= '</div>';
        return $out;
    }

    /**
     * Render a single backlink in the requested mode.
     * Extracted so the test suite can assert per-row markup without
     * setting up the full wrapper.
     */
    private static function renderOne(int $id, string $url, string $anchor, string $rel, string $host, string $mode): string
    {
        $safeUrl    = esc_url($url);
        $safeAnchor = esc_html($anchor);
        $safeRel    = esc_attr($rel);
        $safeHost   = $host !== '' ? esc_html($host) : '';
        // F238: backlink_id is what the JS click handler reads via data-swapads-sid
        // to enqueue click events. Without this, F238 statistics don't track.
        $dataAttr   = $id > 0 ? ' data-swapads-sid="' . esc_attr((string) $id) . '"' : '';

        switch ($mode) {
            case self::MODE_CARD:
                $html  = '<div class="swapads-backlink-card">';
                $html .= '<a class="swapads-backlink" href="' . $safeUrl . '" rel="' . $safeRel . '" target="_blank"' . $dataAttr . '>' . $safeAnchor . '</a>';
                if ($safeHost !== '') {
                    $html .= '<span class="swapads-backlink-host">' . $safeHost . '</span>';
                }
                $html .= '</div>';
                return $html;

            case self::MODE_TEXT:
                return '<a class="swapads-backlink swapads-backlink-inline" href="' . $safeUrl . '" rel="' . $safeRel . '" target="_blank"' . $dataAttr . '>' . $safeAnchor . '</a> ';

            case self::MODE_LIST:
            default:
                $html  = '<div class="swapads-backlink-item">';
                $html .= '<a class="swapads-backlink" href="' . $safeUrl . '" rel="' . $safeRel . '" target="_blank"' . $dataAttr . '>' . $safeAnchor . '</a>';
                if ($safeHost !== '') {
                    $html .= ' <span class="swapads-backlink-host">(' . $safeHost . ')</span>';
                }
                $html .= '</div>';
                return $html;
        }
    }

    /**
     * Clamp the requested count to the allowed range.
     */
    public static function clampCount(int $count): int
    {
        if ($count < self::MIN_COUNT) {
            return self::MIN_COUNT;
        }
        if ($count > self::MAX_COUNT) {
            return self::MAX_COUNT;
        }
        return $count;
    }

    /**
     * Normalize the mode to one of the allowed values. Defaults to
     * MODE_LIST when the input is unknown.
     */
    public static function normalizeMode(string $mode): string
    {
        $mode = strtolower(trim($mode));
        if (in_array($mode, self::ALLOWED_MODES, true)) {
            return $mode;
        }
        return self::MODE_LIST;
    }
}
