<?php
/**
 * Backlink Create Admin Page (CF14).
 *
 * Operator-facing form for creating a new backlink offer via the
 * server REST API (POST /swapads-server/v1/backlinks/offer).
 *
 * Per _UX-SPEC.md §2.4:
 *   - Source URL (must be on operator's own site)
 *   - Hint to partners (single line, max 250 chars)
 *   - Anchor text (default: site name)
 *   - Rel attribute (dofollow / nofollow / sponsored)
 *   - Max external links on source page
 *   - Target audience override (advanced, defaults to site audience)
 *
 * All render output goes through Admin\Renderer so the UI seam
 * stays in one place — see Renderer.php.
 *
 * @package SwapAds\Client\Admin
 * @since   1.0.0
 */

declare(strict_types=1);

namespace SwapAds\Client\Admin;

use SwapAds\Client\Admin\ClientErrorRenderer;
use SwapAds\Client\Api\RestClient;
use SwapAds\Client\Audience\SiteAudience;
use SwapAds\Client\Http\Redirect;
use SwapAds\Client\Http\Request;
use SwapAds\Client\License\LicenseManager;

/**
 * Class BacklinkCreatePage.
 *
 * @since 1.0.0
 */
final class BacklinkCreatePage
{
    public const MENU_SLUG     = 'swapads-client-backlinks';
    public const NONCE_ACTION  = 'swapads_client_create_backlink';
    public const FORM_ACTION   = 'swapads_client_create_backlink';
    public const ERROR_CONTEXT  = 'swapads_client_backlink_create';
    public const MAX_HINT_LEN   = 250;
    public const MAX_ANCHOR_LEN = 255;  // TODO-CLIENT-002 (2026-08-02): hard cap on anchor text length
    public const REL_OPTIONS   = ['dofollow', 'nofollow', 'sponsored'];

    /**
     * Register WP hooks.
     *
     * Menu registration moved to BacklinksHubPage (F200 tabbed UI).
     * `add_submenu_page` here is a no-op kept for back-compat.
     */
    public static function register(): void
    {
        add_action('admin_menu', [self::class, 'addMenu']);
        // F2XX-fix 2026-08-01: hide the sidebar link via CSS instead of
        // remove_submenu_page() (which broke the cap check in WP 7.0 — admin-post.php
        // routing rejected 'manage_options' users with 'Sorry, you are not allowed
        // to access this page' because user_can_access_admin_page() iterates
        // $submenu[$parent] and the removed entry caused a mismatch).
        add_action('admin_head', [self::class, 'hideSubmenuFromSidebar']);
        add_action('admin_post_' . self::FORM_ACTION, [self::class, 'handleSubmit']);
        add_action('admin_post_swapads_delete_backlink', [self::class, 'handleDelete']);
        // F121 (2026-07-31): manual source-check trigger (per-row + bulk)
        add_action('admin_post_swapads_source_check_now', [self::class, 'handleSourceCheckNow']);
        add_action('admin_post_swapads_source_check_bulk', [self::class, 'handleSourceCheckBulk']);
    }

    /**
     * Hide the 'Create Backlink' submenu entry from the sidebar via CSS.
     * The page itself stays routable (Hub owns the visible menu link).
     *
     * F2XX-fix 2026-08-01: replaces remove_submenu_page() which broke
     * admin-post.php routing in WP 7.0. See register() docblock.
     */
    public static function hideSubmenuFromSidebar(): void
    {
        // F2XX-fix 2026-08-02: use anchored selector (`$=`) instead of `*=`
        // substring match. The old `href*="page=swapads-client-backlinks"` would
        // also match `swapads-client-backlinks-hub` (the new Backlinks Hub) and
        // hide it from the sidebar.
        echo '<style>#adminmenu a[href$="page=' . self::MENU_SLUG . '\"]{display:none!important}</style>';
    }

    /**
     * Register the admin page (sub-menu of swapads-client).
     *
     * DEPRECATED — BacklinksHubPage owns the menu now. Kept as no-op
     * for callers that may still call this directly.
     */
    public static function addMenu(): void
    {
        // F2XX (2026-08-01): re-register the legacy swapads-client-backlinks
        // slug as a HIDDEN submenu. The page itself still renders the full
        // create-backlink form (for direct URL access from old bookmarks +
        // the 'Create Backlink' button). Hub owns the visible 'Backlinks'
        // submenu now; this stays routable but hidden.
        add_submenu_page(
            OperatorDashboardHubPage::MENU_SLUG,
            'Create Backlink',
            'Create Backlink',
            'manage_options',
            self::MENU_SLUG,
            [self::class, 'render']
        );
        // F2XX-fix 2026-08-01: do NOT call remove_submenu_page() — it breaks
        // admin-post.php routing in WP 7.0. The sidebar link is hidden via
        // admin_head CSS (see hideSubmenuFromSidebar).
    }

    /**
     * Render the create-backlink form.
     */
    public static function render(): void
    {
        if (!current_user_can('manage_options')) {
            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
        }

        $licensed = LicenseManager::isLicensed();
        $siteUrl  = home_url();
        $audience = SiteAudience::get();

        echo Renderer::pageHeader(
            'Create Backlink Offer',
            'Submit a backlink offer. Partner operators will see it in their suggestion queue.'
        );

        if (isset($_GET['created'])) {
            $id = Request::inputInt('id', 0, 'get');
            echo Renderer::notice('Backlink offer created' . ($id > 0 ? ' #' . $id : '') . '.', 'success');
        }
        // F121 (2026-07-31): surface flash messages from handleSourceCheckNow /
        // handleSourceCheckBulk (or future handlers that use redirectWithMsg).
        // TODO-CLIENT-001 (2026-08-02): wrap $_GET reads in Request::input()
        // so wp_unslash() is applied (defense in depth for WP 4.x-6.4
        // where magic_quotes_gpc() is on).
        $msgCtx = Request::input('msg_ctx', '', 'get');
        $msgSt  = Request::input('msg_st', '', 'get');
        $msg    = Request::input('msg', '', 'get');
        if ($msgCtx === 'source-check' && $msg !== '') {
            $noticeType = $msgSt === 'ok' ? 'success' : 'warning';
            $decoded = rawurldecode($msg);
            echo '<div class="notice notice-' . esc_attr($noticeType) . ' is-dismissible" style="margin-top:12px;">'
                . '<p><strong>Source check:</strong> ' . esc_html($decoded) . '</p>'
                . '</div>';
        }
        // F198: surface any persisted error from the previous submit
        // (POST flow → redirect → re-render with GET). Stored in transient
        // by handleSubmit() when RestClient::request() returns a failure.
        $persisted = get_transient(self::ERROR_CONTEXT);
        if (is_array($persisted)) {
            echo ClientErrorRenderer::render(self::ERROR_CONTEXT, $persisted, 'create backlink offer');
            delete_transient(self::ERROR_CONTEXT);
        }
        if (!$licensed) {
            echo Renderer::card(
                'License required',
                '<p>You need an active license before creating backlink offers.</p>'
                    . '<p><a href="' . esc_url(admin_url('admin.php?page=' . SettingsPage::MENU_SLUG)) . '" class="button">Activate license</a></p>',
                'swapads-card-warning'
            );
            echo Renderer::pageFooter();
            return;
        }

        $defaults = self::defaultValues();
        $body  = Renderer::formOpen(self::FORM_ACTION, self::NONCE_ACTION, 'Create Offer');
        $body .= Renderer::formTableOpen();
        $body .= Renderer::textField(
            'source_url',
            'Source URL *',
            $defaults['source_url'],
            'https://' . wp_parse_url($siteUrl, PHP_URL_HOST) . '/partners',
            'Must be on your own site. Legal pages (Privacy, ToS) are excluded.'
        );
        $body .= Renderer::textField(
            'hint',
            'Hint to partners',
            $defaults['hint'],
            'e.g. Adult cam review site, mostly EU traffic',
            'Single line, max ' . self::MAX_HINT_LEN . ' characters.'
        );
        $body .= Renderer::textField(
            'anchor_text',
            'Anchor text *',
            $defaults['anchor_text'],
            get_bloginfo('name'),
            'Default: site name.'
        );
        $body .= Renderer::radioField(
            'rel_attribute',
            'Link type *',
            array_map(static fn (string $v) => ['value' => $v, 'label' => ucfirst($v)], self::REL_OPTIONS),
            $defaults['rel_attribute'],
            ''
        );
        $body .= Renderer::textField(
            'max_external_on_source',
            'Max external links on source page',
            (string) $defaults['max_external_on_source'],
            '5',
            'Other operators will see this number.'
        );
        // Target audience override — default to site audience
        $body .= Renderer::textareaField(
            'target_audience_override',
            'Target audience (JSON, optional)',
            '',
            3,
            'Override the site audience for this offer. Leave blank to use your site\'s audience: '
                . esc_html(json_encode($audience))
        );
        $body .= Renderer::formTableClose();
        $body .= '<p class="submit">';
        $body .= '<button type="submit" class="button button-primary">Create Offer</button> ';
        $body .= '<a href="' . esc_url(admin_url('admin.php?page=swapads-client')) . '" class="button">Cancel</a>';
        $body .= '</p>';
        $body .= '</form>';
        echo Renderer::card('', $body);
        echo Renderer::pageFooter();
    }

    /**
     * Compute default form values from license/site state.
     *
     * @return array<string, mixed>
     */
    public static function defaultValues(): array
    {
        $host = wp_parse_url(home_url(), PHP_URL_HOST) ?: '';
        return [
            'source_url'           => '',
            'hint'                 => '',
            'anchor_text'          => get_bloginfo('name') ?: '',
            'rel_attribute'        => 'dofollow',
            'max_external_on_source' => 5,
        ];
    }

    /**
     * Validate + persist the form.
     */
    public static function handleSubmit(): void
    {
        if (!current_user_can('manage_options')) {
            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
        }
        check_admin_referer(self::NONCE_ACTION);

        // TODO-CLIENT-001 (2026-08-02): use Request::input* so every $_POST
        // read is wrapped in wp_unslash() (defense in depth for WP 4.x-6.4
        // where magic_quotes_gpc() is on).
        $sourceUrl   = Request::input('source_url', '', 'post');
        $hint        = Request::input('hint', '', 'post');
        $anchorText  = Request::input('anchor_text', '', 'post');
        $rel         = Request::input('rel_attribute', 'dofollow', 'post');
        $maxExternal = Request::inputInt('max_external_on_source', 5, 'post');
        $overrideRaw = Request::input('target_audience_override', '', 'post');

        // Validate
        if ($sourceUrl === '') {
            self::redirectWithError('source_url is required');
            return;
        }
        if (!wp_http_validate_url($sourceUrl)) {
            self::redirectWithError('source_url is not a valid URL');
            return;
        }
        // Source URL must be on operator's own domain
        $siteHost = wp_parse_url(home_url(), PHP_URL_HOST);
        $srcHost  = wp_parse_url($sourceUrl, PHP_URL_HOST);
        if (!$siteHost || !$srcHost || strcasecmp($siteHost, $srcHost) !== 0) {
            self::redirectWithError('source_url must be on your own site (' . $siteHost . ')');
            return;
        }
        if ($anchorText === '') {
            self::redirectWithError('anchor_text is required');
            return;
        }
        if (!in_array($rel, self::REL_OPTIONS, true)) {
            self::redirectWithError('invalid rel_attribute');
            return;
        }
        if (strlen($hint) > self::MAX_HINT_LEN) {
            self::redirectWithError('hint exceeds ' . self::MAX_HINT_LEN . ' chars');
            return;
        }
        if ($maxExternal < 1 || $maxExternal > 50) {
            self::redirectWithError('max_external_on_source must be 1-50');
            return;
        }
        // TODO-CLIENT-002 (2026-08-02): sanitize anchor_text + hint before sending
        // to the server (XSS defense — anchor text is rendered on partner
        // sites via BacklinksRenderer). Request::input() already
        // wp_unslash()'d, but sanitize_text_field also strips HTML tags
        // and trims length.
        $anchorText = sanitize_text_field($anchorText);
        $hint       = sanitize_text_field($hint);
        if (strlen($anchorText) > self::MAX_ANCHOR_LEN) {
            self::redirectWithError('anchor_text exceeds ' . self::MAX_ANCHOR_LEN . ' chars');
            return;
        }

        // Build payload
        $payload = [
            'source_url'            => esc_url_raw($sourceUrl),
            'anchor_text'           => $anchorText,
            'rel_attribute'         => $rel,
            'max_external_on_source' => $maxExternal,
        ];
        if ($hint !== '') {
            $payload['hint'] = $hint;
        }
        if ($overrideRaw !== '') {
            $decoded = json_decode($overrideRaw, true);
            if (!is_array($decoded)) {
                self::redirectWithError('target_audience_override is not valid JSON');
                return;
            }
            $payload['target_audience_override'] = $decoded;
        }

        // POST to server
        $response = RestClient::fromOption()->post('/v1/backlinks/offer', $payload);
        if (!is_array($response)) {
            self::redirectWithError('server returned no response');
            return;
        }
        if (isset($response['error_code']) || isset($response['code'])) {
            $msg = (string) ($response['error_code'] ?? $response['code'] ?? 'unknown');
            $detail = (string) ($response['message'] ?? '');
            self::redirectWithError($msg . ($detail !== '' ? ' — ' . $detail : ''));
            return;
        }
        $id = (int) ($response['id'] ?? 0);
        $redirect = add_query_arg(
            ['page' => self::MENU_SLUG, 'created' => '1', 'id' => $id],
            admin_url('admin.php')
        );
        Redirect::to($redirect);
        
    }

    private static function redirectWithError(string $message): void
    {
        $redirect = add_query_arg(
            ['page' => self::MENU_SLUG, 'error' => rawurlencode($message)],
            admin_url('admin.php')
        );
        Redirect::to($redirect);
        
    }

            
    /**
     * Render the "Your Backlinks" section (form + existing list).
     *
     * Called by BacklinksHubPage (F200 tabbed UI). Displays the create
     * form at the top + a table of operator's existing backlinks below,
     * each with edit/remove buttons.
     *
     * @since 1.5.2
     */
    public static function renderYourBacklinksSection(): void
    {
        $rest = RestClient::fromOption();
        $listResp = $rest->listBacklinks();
        /** @var array<int, array<string, mixed>> $backlinks */
        $backlinks = [];
        if (isset($listResp['backlinks']) && is_array($listResp['backlinks'])) {
            $backlinks = $listResp['backlinks'];
        }
        $error = $rest->lastError();

        // Surface any inline error from the most recent attempt
        $persisted = get_transient(self::ERROR_CONTEXT);
        if (is_array($persisted)) {
            echo ClientErrorRenderer::render(self::ERROR_CONTEXT, $persisted, 'manage backlinks');
            delete_transient(self::ERROR_CONTEXT);
        }
        if ($error !== null && !is_array($persisted)) {
            $errStr = (string) wp_json_encode($error);
            echo ClientErrorRenderer::render(self::ERROR_CONTEXT, [
                'code'    => 'BACKEND_UNREACHABLE',
                'message' => $errStr,
            ], 'load backlinks');
        }

        // ===== Form card (create + edit) =====
        $editId = Request::inputInt('edit', 0, 'get');
        $editing = null;
        if ($editId > 0) {
            foreach ($backlinks as $b) {
                if ((int) ($b['id'] ?? 0) === $editId) {
                    $editing = $b;
                    break;
                }
            }
        }
        $defaults = $editing
            ? [
                'source_url'             => (string) ($editing['source_url'] ?? ''),
                'hint'                   => (string) ($editing['hint'] ?? ''),
                'anchor_text'            => (string) ($editing['anchor_text'] ?? ''),
                'rel_attribute'          => (string) ($editing['rel_attribute'] ?? 'dofollow'),
                'max_external_on_source' => (int) ($editing['max_external_on_source'] ?? 5),
            ]
            : self::defaultValues();

        echo '<div class="swapads-card" style="margin-top:16px">';
        echo '<h2>' . esc_html($editId > 0 ? 'Edit backlink #' . $editId : 'Create new backlink offer') . '</h2>';
        echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
        echo '<input type="hidden" name="action" value="' . esc_attr(self::FORM_ACTION) . '">';
        if ($editId > 0) {
            echo '<input type="hidden" name="edit_id" value="' . esc_attr((string) $editId) . '">';
            echo '<input type="hidden" name="original_source_url" value="' . esc_attr((string) ($editing['source_url'] ?? '')) . '">';
        }
        wp_nonce_field(self::NONCE_ACTION);
        $siteUrl = home_url();
        echo '<table class="form-table" role="presentation"><tbody>';
        $disabledHelp = $editId > 0 ? 'Source URL cannot be changed. Remove + recreate if needed.' : '';
        $srcUrlValue  = $defaults['source_url'];
        echo '<tr><th scope="row"><label for="source_url">Source URL *</label></th><td>';
        if ($editId > 0) {
            echo '<input type="url" id="source_url" name="source_url" value="' . esc_attr($srcUrlValue) . '" '
                . 'class="regular-text" readonly aria-readonly="true" />';
            if ($disabledHelp !== '') {
                echo '<p class="description">' . esc_html($disabledHelp) . '</p>';
            }
        } else {
            echo '<input type="url" id="source_url" name="source_url" value="' . esc_attr($srcUrlValue) . '" '
                . 'class="regular-text" placeholder="https://' . esc_attr(wp_parse_url($siteUrl, PHP_URL_HOST) ?: 'example.com') . '/your-article" />';
        }
        echo '</td></tr>';
        echo Renderer::textField(
            'anchor_text',
            'Anchor text *',
            $defaults['anchor_text'],
            'Your brand name'
        );
        echo Renderer::textareaField(
            'hint',
            'Hint for partner operators',
            $defaults['hint'],
            4,
            'e.g. relevant to your niche, accepts guest posts, has comments open'
        );
        echo Renderer::selectField(
            'rel_attribute',
            'Rel attribute',
            [
                ['value' => 'dofollow',  'label' => 'dofollow'],
                ['value' => 'nofollow',  'label' => 'nofollow'],
                ['value' => 'sponsored', 'label' => 'sponsored'],
            ],
            $defaults['rel_attribute'],
            ''
        );
        echo Renderer::textField(
            'max_external_on_source',
            'Max external links on source',
            (string) $defaults['max_external_on_source'],
            '5',
            'Limits how many other backlink offers can be placed on this URL.'
        );
        echo '</tbody></table>';
        echo '<p>';
        if ($editId > 0) {
            submit_button('Save changes', 'primary', 'submit', false);
            echo ' <a href="' . esc_url(remove_query_arg('edit')) . '" class="button">Cancel</a>';
        } else {
            submit_button('Create backlink offer', 'primary', 'submit', false);
        }
        echo '</p>';
        echo '</form>';
        echo '</div>';

        // ===== Existing list =====
        echo '<h2 style="margin-top:24px">Your backlinks</h2>';
        if (empty($backlinks)) {
            // F220 (2026-07-30): Two-way transparency — show who approved MY backlinks.
        // Operator A offers; Operator B approves. A wants to see who approved what.
        $approvalsResp = $rest->listBacklinkApprovals(0, '', 25);
        $approvals = (is_array($approvalsResp) && isset($approvalsResp['items']) && is_array($approvalsResp['items']))
            ? $approvalsResp['items']
            : [];
        if (count($approvals) > 0) {
            echo '<div class="swapads-card" style="margin-top:16px">';
            echo '<h2>Recent approvals of your backlinks</h2>';
            echo '<p class="description">These sites have approved your backlink offers. The backlink goes live as soon as one of them places it on a real page.</p>';
            echo '<table class="widefat striped">';
            echo '<thead><tr>';
            echo '<th>Approver</th>';
            echo '<th>Your backlink</th>';
            echo '<th>Anchor</th>';
            echo '<th>Status</th>';
            echo '<th>When</th>';
            echo '</tr></thead><tbody>';
            foreach ($approvals as $ap) {
                $approverDomain = (string) ($ap['approver_site_domain'] ?? '');
                $approverUrl    = (string) ($ap['approver_site_url'] ?? '');
                $sourceUrl      = (string) ($ap['source_url'] ?? '');
                $anchorText     = (string) ($ap['anchor_text'] ?? '');
                $apStatus       = (string) ($ap['status'] ?? 'pending');
                $approvedAt     = (string) ($ap['approved_at'] ?? '');
                $createdAt      = (string) ($ap['created_at'] ?? '');
                $displayTime    = $approvedAt !== '' ? $approvedAt : $createdAt;

                echo '<tr>';
                echo '<td>';
                if ($approverUrl !== '') {
                    echo '<strong>' . esc_html($approverDomain !== '' ? $approverDomain : $approverUrl) . '</strong><br>';
                    echo '<small><code>' . esc_html($approverUrl) . '</code></small>';
                } else {
                    echo '<span class="description">Site #' . (int) ($ap['approver_site_id'] ?? 0) . '</span>';
                }
                echo '</td>';
                echo '<td><code>' . esc_html(wp_parse_url($sourceUrl, PHP_URL_HOST) ?: $sourceUrl) . '</code></td>';
                echo '<td>' . esc_html($anchorText) . '</td>';
                echo '<td><span class="swapads-status swapads-status--' . esc_attr($apStatus) . '">' . esc_html(ucfirst($apStatus)) . '</span></td>';
                echo '<td>' . esc_html($displayTime !== '' ? mysql2date('Y-m-d H:i', $displayTime) : '—') . '</td>';
                echo '</tr>';
            }
            echo '</tbody></table>';
            echo '</div>';
        }

        echo Renderer::renderEmptyState(
                'No backlinks yet',
                'Create your first backlink offer above. Partner operators will see it in their suggestion queue.',
                null,
                null
            );
        } else {
            echo self::renderBacklinksTable($backlinks);
        }
    }

    /**
     * Render the operator's backlink list as a WP admin table.
     *
     * @param array<int, array<string, mixed>> $backlinks Rows from BacklinkRepository::listWithStats.
     * @return string HTML.
     *
     * @since 1.5.2
     */
    public static function renderBacklinksTable(array $backlinks): string
    {
        $h  = '<table class="widefat striped" role="table" aria-label="Your backlinks">';
        $h .= '<thead><tr>';
        $h .= '<th scope="col">ID</th>';
        $h .= '<th scope="col">Source URL</th>';
        $h .= '<th scope="col">Anchor</th>';
        $h .= '<th scope="col">Status</th>';
        $h .= '<th scope="col">Placements</th>';
        $h .= '<th scope="col">Last viewed</th>';
        // F121 (2026-07-31): new Health column + check-now button per row
        $h .= '<th scope="col">Source health</th>';
        $h .= '<th scope="col">Actions</th>';
        $h .= '</tr></thead><tbody>';
        foreach ($backlinks as $b) {
            $id      = (int) ($b['id'] ?? 0);
            $url     = (string) ($b['source_url'] ?? '');
            $anchor  = (string) ($b['anchor_text'] ?? '');
            $status  = (string) ($b['status'] ?? '');
            $pcount  = (int) ($b['placement_count'] ?? 0);
            $lastV   = $b['last_viewed_at'] ?? null;
            // F121: source-check fields
            $lastCheckAt    = (string) ($b['last_source_check_at'] ?? '');
            $lastCheckSt    = strtolower(trim((string) ($b['last_source_check_status'] ?? '')));
            $editUrl = esc_url(add_query_arg('edit', $id));
            $delUrl  = esc_url(wp_nonce_url(
                add_query_arg(['action' => 'swapads_delete_backlink', 'id' => $id]),
                'swapads_delete_backlink_' . $id
            ));
            // F121: per-row "Check now" link (GET, same pattern as approve)
            $checkUrl = esc_url(add_query_arg(
                [
                    'action'      => 'swapads_source_check_now',
                    'backlink_id' => $id,
                    '_wpnonce'    => wp_create_nonce('swapads_source_check_now'),
                ],
                admin_url('admin-post.php')
            ));
            $h .= '<tr>';
            $h .= '<td>' . $id . '</td>';
            $h .= '<td><code>' . esc_html(wp_parse_url($url, PHP_URL_HOST) ?: $url) . '</code><br><small>' . esc_html($url) . '</small></td>';
            $h .= '<td>' . esc_html($anchor) . '</td>';
            $h .= '<td><span class="swapads-status swapads-status--' . esc_attr($status) . '">' . esc_html(ucfirst($status)) . '</span></td>';
            $h .= '<td>' . $pcount . '</td>';
            $h .= '<td>' . ($lastV ? esc_html(mysql2date('Y-m-d H:i', (string) $lastV)) : '<span aria-hidden="true">—</span>') . '</td>';
            // F121: Health cell — shows status badge + age + Check now link
            $healthCell = '<span class="swapads-muted">Never checked</span>';
            if ($lastCheckAt !== '' && $lastCheckAt !== '0000-00-00 00:00:00') {
                $checkLabel = ucfirst($lastCheckSt !== '' ? $lastCheckSt : 'unknown');
                $healthCell = '<span class="swapads-health swapads-health--' . esc_attr($lastCheckSt !== '' ? $lastCheckSt : 'unknown') . '">'
                    . esc_html($checkLabel)
                    . '</span><br><small>' . esc_html(mysql2date('Y-m-d H:i', $lastCheckAt)) . '</small>';
            }
            $healthCell .= '<br><a href="' . $checkUrl . '" class="button button-small" '
                         . 'style="margin-top:4px;">Check now</a>';
            $h .= '<td>' . $healthCell . '</td>';
            $h .= '<td>';
            $h .= '<a href="' . $editUrl . '" class="button button-small">Edit</a> ';
            $h .= '<a href="' . $delUrl . '" class="button button-small button-link-delete"'
                . ' onclick="return confirm(\'Remove backlink #' . $id . '? Partners who already placed it will keep the link, but no new placements will be made.\');">'
                . 'Remove</a>';
            $h .= '</td>';
            $h .= '</tr>';
        }
        $h .= '</tbody></table>';
        return $h;
    }

    /**
     * Handle delete POST (admin-post.php?action=swapads_delete_backlink).
     *
     * Soft-delete via RestClient::deleteBacklink(). Redirects back to
     * the hub page with status param.
     *
     * @since 1.5.2
     */
    public static function handleDelete(): void
    {
        if (!current_user_can('manage_options')) {
            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
        }
        $id = Request::inputInt('id', 0, 'request');
        if ($id <= 0) {
            self::redirectWithError('invalid backlink id');
            return;
        }
        check_admin_referer('swapads_delete_backlink_' . $id);
        $rest = RestClient::fromOption();
        $resp = $rest->deleteBacklink($id);
        $err  = $rest->lastError();
        if ($err !== null) {
            $errMsg = (string) wp_json_encode($err);
            self::redirectWithError($errMsg);
            return;
        }
        $success = isset($resp['success']) ? (bool) $resp['success'] : false;
        if (!$success) {
            self::redirectWithError('Delete failed');
            return;
        }
        Redirect::to(add_query_arg(['page' => BacklinksHubPage::MENU_SLUG, 'removed' => $id], admin_url('admin.php')));
    }

    /**
     * Handle single source-check-now submission (per-row "Check now" link).
     *
     * F121 (2026-07-31): GET link from the per-row button (uses $_REQUEST).
     * Triggers a single-round-trip source-check via RestClient::recheckSource().
     *
     * @since 1.5.2
     */
    public static function handleSourceCheckNow(): void
    {
        if (!current_user_can('manage_options')) {
            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
        }
        check_admin_referer('swapads_source_check_now');

        $backlinkId = Request::inputInt('backlink_id', 0, 'request');
        if ($backlinkId <= 0) {
            self::redirectWithMsg('source-check', 'error', 'backlink_id is required');
            return;
        }
        $client = RestClient::fromOption();
        $resp = $client->recheckSource([$backlinkId]);
        if ($client->lastError() !== null) {
            self::redirectWithMsg('source-check', 'error', 'Server unreachable');
            return;
        }
        $results = is_array($resp) && isset($resp['results']) && is_array($resp['results']) ? $resp['results'] : [];
        $first   = $results[0] ?? [];
        $result  = (string) ($first['result'] ?? 'unknown');
        $http    = (int) ($first['http_status'] ?? 0);
        self::redirectWithMsg(
            'source-check',
            $result,
            sprintf('Backlink #%d: %s (HTTP %d)', $backlinkId, strtoupper($result), $http)
        );
    }

    /**
     * Handle bulk source-check-now submission.
     *
     * F121 (2026-07-31): single round-trip for many backlinks.
     *
     * @since 1.5.2
     */
    public static function handleSourceCheckBulk(): void
    {
        if (!current_user_can('manage_options')) {
            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
        }
        check_admin_referer('swapads_source_check_bulk');

        $ids = Request::inputArray('backlink_ids', [], 'post');
        if (!is_array($ids) || empty($ids)) {
            self::redirectWithMsg('source-check', 'error', 'No backlinks selected');
            return;
        }
        $client = RestClient::fromOption();
        $resp = $client->recheckSource($ids);
        if ($client->lastError() !== null) {
            self::redirectWithMsg('source-check', 'error', 'Server unreachable');
            return;
        }
        $count = (int) ($resp['count'] ?? 0);
        self::redirectWithMsg(
            'source-check',
            'ok',
            sprintf('Source-checked %d backlink(s)', $count)
        );
    }

    /**
     * Helper: redirect back to the Your Backlinks tab with a flash msg.
     *
     * F121 (2026-07-31): used by handleSourceCheckNow / handleSourceCheckBulk.
     * F121/2026-08-02 (TODO-CLIENT-005): now delegates to Redirect::to()
     * which internally handles the SWAPADS_TESTING_REDIRECT_EXIT guard.
     *
     * @since 1.5.2
     */
    private static function redirectWithMsg(string $context, string $status, string $message): void
    {
        Redirect::to(add_query_arg(
            [
                'page'    => 'swapads-client-backlinks-hub',
                'tab'     => 'your',
                'msg_ctx' => $context,
                'msg_st'  => $status,
                'msg'     => rawurlencode($message),
            ],
            admin_url('admin.php')
        ));
    }

}
