<?php
/**
 * Backlink Approval Queue (CF15) — Operator B's view.
 *
 * Lists backlink suggestions pulled from server
 * (GET /swapads-server/v1/backlinks/match) and lets the operator approve
 * or reject each one inline.
 *
 * Per _UX-SPEC.md §2.5:
 *   - Sortable columns: source, anchor, match, trust, reciprocal risk
 *   - Bulk actions: approve + reject
 *   - Reciprocal warning when the requester already links to the offerer
 *   - One-click approve -> POST /v1/backlinks/approve
 *
 * Per "build-for-change" rule (locked 2026-07-28): every render path
 * goes through Admin\Renderer static helpers.
 *
 * @package SwapAds\Client\Admin
 * @since   1.0.0
 */

declare(strict_types=1);

namespace SwapAds\Client\Admin;

use SwapAds\Client\Api\RestClient;
use SwapAds\Client\Http\Redirect;
use SwapAds\Client\License\LicenseManager;
use SwapAds\Client\Http\Request;

/**
 * Class BacklinkApprovalPage.
 *
 * @since 1.0.0
 */
final class BacklinkApprovalPage
{
    public const MENU_SLUG    = 'swapads-client-approval';
    public const NONCE_ACTION = 'swapads_client_approve_backlink';
    public const NONCE_ACTION_BULK = 'swapads_client_approve_bulk';
    public const NONCE_ACTION_REJECT      = 'swapads_client_reject_backlink';
    public const NONCE_ACTION_REJECT_BULK = 'swapads_client_reject_bulk';
    public const RECIPROCAL_WARN_THRESHOLD = 0.2;
    public const DEFAULT_LIMIT = 25;
    public const BULK_MAX = 50;
    public const ERROR_CONTEXT  = 'swapads_client_backlink_approve';

    /**
     * Register WP hooks.
     */
    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).
        add_action('admin_head', [self::class, 'hideSubmenuFromSidebar']);
        add_action('admin_post_' . self::NONCE_ACTION, [self::class, 'handleApprove']);
        add_action('admin_post_' . self::NONCE_ACTION_BULK, [self::class, 'handleApproveBulk']);
        add_action('admin_post_' . self::NONCE_ACTION_REJECT, [self::class, 'handleReject']);
        add_action('admin_post_' . self::NONCE_ACTION_REJECT_BULK, [self::class, 'handleRejectBulk']);
    }

    /**
     * Register as sub-menu of swapads-client.
     *
     * DEPRECATED — BacklinksHubPage owns the menu now.
     */
    public static function addMenu(): void
    {
        // F2XX (2026-08-01): re-register the legacy swapads-client-approval
        // slug as a HIDDEN submenu. Renders the suggestions queue (see render)
        // for the 'Review Suggestions' button + old bookmarks. Hub owns the
        // visible 'Backlinks' submenu.
        add_submenu_page(
            OperatorDashboardHubPage::MENU_SLUG,
            'Review Suggestions',
            'Review Suggestions',
            '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. Sidebar link is hidden via
        // admin_head CSS (see hideSubmenuFromSidebar).
    }

    /**
     * Fetch suggestions from server.
     *
     * F202 (2026-07-30): accept optional offset for pagination. Operator
     * clicks Next → URL gets ?offset=N → server returns the next page.
     *
     * @return array{count: int, items: array<int, array<string, mixed>>, error?: string, limit?: int, offset?: int}
     */
    public static function fetchSuggestions(int $limit = self::DEFAULT_LIMIT, int $offset = 0): array
    {
        // F198: RestClient::request() no longer throws for REST/transport
        // failures; we read lastError() to surface inline.
        $client = RestClient::fromOption();
        $response = $client->get('/v1/backlinks/match', ['limit' => $limit, 'offset' => $offset]);
        if (!is_array($response) || !isset($response['items']) || !is_array($response['items'])) {
            if (($err = $client->lastError()) !== null) {
                set_transient(self::ERROR_CONTEXT, $err, MINUTE_IN_SECONDS);
                return ['count' => 0, 'items' => [], 'error' => (string) ($err['error_code'] ?? 'UNKNOWN')];
            }
            return ['count' => 0, 'items' => []];
        }
        return [
            'count'  => (int) ($response['count'] ?? count($response['items'])),
            'items'  => $response['items'],
            'limit'  => (int) ($response['limit'] ?? $limit),
            'offset' => (int) ($response['offset'] ?? $offset),
        ];
    }

    /**
     * Render the full approval page (back-compat entry point).
     *
     * @deprecated F200: BacklinksHubPage owns the menu now. Kept for
     *             callers that may still dispatch to MENU_SLUG directly.
     */
    public static function render(): void
    {
        if (!current_user_can('manage_options')) {
            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
        }

        echo '<div class="wrap swapads-page">';
        echo '<h1>' . esc_html__('Backlink Suggestions', 'swapads-client') . '</h1>';
        echo '<p class="description">'
            . esc_html__('Approve or reject backlink offers from partner operators.', 'swapads-client')
            . '</p>';

        if (!LicenseManager::isLicensed()) {
            echo Renderer::card(
                'License required',
                '<p>Activate your license before viewing backlink suggestions.</p>',
                'swapads-card-warning'
            );
            echo '</div>';
            return;
        }

        self::renderSuggestionsCard();
        echo '</div>';
    }

    /**
     * Render the Place Backlinks tab body (suggestion queue).
     *
     * Called by BacklinksHubPage (F200 tabbed UI). Outputs:
     * - success notice if ?approved=ID was passed
     * - persisted error from previous failed load
     * - suggestion stats bar + reciprocal-risk warning
     * - the suggestion table (per-row approve forms) + bulk form
     *
     * @since 1.5.2
     */
    public static function renderSuggestionsCard(): void
    {
        if (isset($_GET['approved'])) {
            $id = Request::inputInt('id', 0, 'get');
            echo Renderer::notice('Backlink #' . ($id > 0 ? $id : '') . ' approved.', 'success');
        }
        // F198: surface persisted error from previous submit/load attempt.
        $persisted = get_transient(self::ERROR_CONTEXT);
        if (is_array($persisted)) {
            echo ClientErrorRenderer::render(self::ERROR_CONTEXT, $persisted, 'load backlink suggestions');
            delete_transient(self::ERROR_CONTEXT);
        }

        // F202: read ?offset= from query for pagination
        $offset = Request::inputInt('offset', 0, 'get');
        $offset = max(0, min($offset, 1000));
        $data = self::fetchSuggestions(self::DEFAULT_LIMIT, $offset);
        $limit = (int) ($data['limit'] ?? self::DEFAULT_LIMIT);
        if (isset($data['error'])) {
            echo Renderer::card(
                'Server unreachable',
                '<p>Could not fetch suggestions: <code>' . esc_html($data['error']) . '</code></p>',
                'swapads-card-warning'
            );
            return;
        }

        $count  = $data['count'];
        $items  = $data['items'];
        $reciprocalCount = 0;
        foreach ($items as $it) {
            if ((float) ($it['reciprocal_risk'] ?? 0) >= self::RECIPROCAL_WARN_THRESHOLD) {
                $reciprocalCount++;
            }
        }

        // Top stats bar
        $stats  = '<p>';
        $stats .= '<span class="swapads-stat"><strong>' . (int) $count . '</strong> suggestions</span> &nbsp;|&nbsp; ';
        $stats .= '<span class="swapads-stat"><strong>' . $reciprocalCount . '</strong> with reciprocal warning</span>';
        $stats .= '</p>';

        if ($reciprocalCount > 0) {
            $stats .= Renderer::notice(
                'Some suggestions create direct A↔B reciprocal links. Search engines may discount reciprocal link networks — review these carefully.',
                'warning'
            );
        }
        echo Renderer::card('Queue', $stats);

        if ($count === 0) {
            echo Renderer::card(
                '',
                '<p>No backlink suggestions right now. Check back later, or refine your audience profile to see more partners.</p>'
            );
            return;
        }

        // F202 (2026-07-30): the bulk form must WRAP the checkboxes, not
        // sit outside the table. Old code had:
        //   <table>...<input type="checkbox" name="backlink_ids[]">...</table>
        //   <form>...</form>
        // which meant no checkbox was ever inside the form (the per-row
        // approve forms were nested inside <td>, also bad).
        //
        // New structure:
        //   <form>
        //     <table>
        //       <input type="hidden" name="action" value="bulk_approve">
        //       <input type="hidden" name="_wpnonce" value="...">
        //       <thead>... checkbox (select-all) ...</thead>
        //       <tbody>
        //         <tr>
        //           <td><input type="checkbox" name="backlink_ids[]"></td>
        //           <td><form>...per-row approve...</form></td>
        //         </tr>
        //       </tbody>
        //     </table>
        //     <p>Bulk: <input name="custom_message"> [Approve selected]</p>
        //   </form>
        //
        // Note: HTML forbids nested <form> tags, so we use the per-row
        // approve buttons via an admin-post URL + GET param (not nested
        // form). The bulk form covers the checkboxes.

        // Open bulk form first, then table
        $bulkOpen = '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" id="swapads-bulk-form">';
        $bulkOpen .= wp_nonce_field('swapads_client_approve_bulk', '_wpnonce', true, false);
        $bulkOpen .= '<input type="hidden" name="action" value="swapads_client_approve_bulk">';

        // Build the table
        $body  = $bulkOpen;
        $body .= '<table class="widefat striped">';
        $body .= '<thead><tr>';
        $body .= '<th class="check-column"><input type="checkbox" id="swapads-select-all"></th>';
        $body .= '<th>Source</th>';
        $body .= '<th>Anchor</th>';
        $body .= '<th>Match</th>';
        $body .= '<th>Rel</th>';
        $body .= '<th>Risk</th>';
        $body .= '<th>Hint</th>';
        $body .= '<th>Actions</th>';
        $body .= '</tr></thead>';
        $body .= '<tbody>';
        foreach ($items as $item) {
            $body .= self::renderRow($item);
        }
        $body .= '</tbody>';
        $body .= '</table>';

        // Bulk submit area
        $body .= '<div class="swapads-bulk-actions" style="margin-top:12px; display:flex; gap:12px; align-items:center; flex-wrap:wrap;">';
        $body .= '<button type="submit" class="button button-primary">Approve selected</button>';
        // F223: bulk reject (separate button, separate nonce + action).
        // We can't put two <submit> buttons in one form easily, so this
        // is a separate form pointing at admin-post.php?action=reject-bulk.
        $body .= '</div>';
        $body .= '</form>';

        // F223: separate bulk-reject form (different action, different nonce)
        $rejectBulkOpen  = '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" id="swapads-reject-bulk-form">';
        $rejectBulkOpen .= wp_nonce_field(self::NONCE_ACTION_REJECT_BULK, '_wpnonce', true, false);
        $rejectBulkOpen .= '<input type="hidden" name="action" value="' . esc_attr(self::NONCE_ACTION_REJECT_BULK) . '">';
        $body .= $rejectBulkOpen;
        $body .= '<div class="swapads-bulk-reject" style="margin-top:8px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">';
        $body .= '<label for="swapads-reject-reason" style="font-size:12px;">Rejection reason (optional):</label>';
        $body .= '<input type="text" id="swapads-reject-reason" name="reason" maxlength="500" '
              . 'placeholder="e.g. Wrong niche, anchor mismatch" '
              . 'style="flex:1; min-width:200px;">';
        $body .= '<button type="submit" class="button" '
              . 'onclick="return confirm(\'Reject all selected backlinks? This will mark them as not-approvable-from-you.\');">'
              . 'Reject selected</button>';
        $body .= '</div>';
        $body .= '</form>';

        // Pagination (F202): Next/Prev via ?offset=N&limit=L query params
        $body .= self::renderPagination($count, $limit, $offset);

        echo Renderer::card('', $body);
    }

    /**
     * Render Next/Prev pagination links for the suggestion queue.
     *
     * @since 1.5.2
     */
    private static function renderPagination(int $count, int $limit, int $offset): string
    {
        $hubUrl = admin_url('admin.php?page=swapads-client-backlinks-hub&tab=place');
        $out  = '<div class="swapads-pagination" style="margin-top:12px; display:flex; gap:12px;">';
        if ($offset > 0) {
            $prevOffset = max(0, $offset - $limit);
            $out .= '<a href="' . esc_url(add_query_arg(['offset' => $prevOffset], $hubUrl)) . '" class="button">&laquo; Previous</a>';
        }
        $out .= '<span class="description">Showing ' . ($offset + 1) . '&ndash;' . ($offset + $count) . '</span>';
        if ($count >= $limit) {
            $nextOffset = $offset + $limit;
            $out .= '<a href="' . esc_url(add_query_arg(['offset' => $nextOffset], $hubUrl)) . '" class="button">Next &raquo;</a>';
        }
        $out .= '</div>';
        return $out;
    }

    /**
     * Render one suggestion row.
     *
     * @param array<string, mixed> $item
     */
    public static function renderRow(array $item): string
    {
        $id          = (int) ($item['backlink_id'] ?? 0);
        $sourceUrl   = (string) ($item['source_url'] ?? '');
        $anchor      = (string) ($item['anchor_text'] ?? '');
        $rel         = (string) ($item['rel_attribute'] ?? 'dofollow');
        $matchScore  = (float) ($item['score'] ?? 0);
        $reciprocal  = (float) ($item['reciprocal_risk'] ?? 0);
        $hint        = (string) ($item['hint'] ?? '');
        // F119 (2026-07-30): per-row direct reciprocity detection.
        // The server already returns reciprocal_factors.direct (0-1) per
        // BACKLINK-MVP-ARCHITECTURE §"Reciprocal Warning (Soft)". When
        // direct >= 0.6 (the weighted score threshold from F42), surface a
        // specific warning: "Domain X already has your backlink. SEO value
        // is higher if you select an alternative."
        $factors        = (array) ($item['reciprocal_factors'] ?? []);
        $directFactor   = (float) ($factors['direct'] ?? 0);
        $isDirectReciprocal = $directFactor >= 0.6;
        $riskWarn       = $reciprocal >= self::RECIPROCAL_WARN_THRESHOLD || $isDirectReciprocal;

        $matchPct    = number_format($matchScore * 100, 0);
        $riskPct     = number_format($reciprocal * 100, 0);
        $relUpper    = strtoupper($rel);
        $host        = wp_parse_url($sourceUrl, PHP_URL_HOST) ?: $sourceUrl;

        $row  = '<tr' . ($riskWarn ? ' class="swapads-row-warning"' : '') . '>';
        $row .= '<th class="check-column" scope="row"><input type="checkbox" name="backlink_ids[]" value="' . esc_attr((string) $id) . '"></th>';
        $row .= '<td>';
        $row .= '<strong>' . esc_html($host) . '</strong><br>';
        $row .= '<code class="swapads-url">' . esc_html($sourceUrl) . '</code>';
        // F119: per-row warning with the specific domain name
        if ($isDirectReciprocal) {
            $row .= '<div class="swapads-reciprocal-warning" '
                . 'role="status" aria-live="polite" '
                . 'style="margin-top:6px; padding:6px 10px; border-left:3px solid #d63638; '
                . 'background:#fef7f7; color:#3a1a1a; font-size:12px; line-height:1.4;">';
            $row .= '<strong>&#9888; Direct reciprocity detected:</strong> '
                . '<em>' . esc_html($host) . '</em> already has your backlink. '
                . 'Some search engines discount reciprocal link networks. '
                . 'SEO value is higher if you select an alternative link.';
            $row .= '</div>';
        }
        $row .= '</td>';
        $row .= '<td>' . esc_html($anchor) . '</td>';
        $row .= '<td><span class="swapads-score">' . esc_html($matchPct) . '%</span></td>';
        $row .= '<td><code>' . esc_html($relUpper) . '</code></td>';
        $row .= '<td>';
        if ($riskWarn) {
            $row .= '<span class="swapads-risk swapads-risk-warn">' . esc_html($riskPct) . '% reciprocal</span>';
        } else {
            $row .= '<span class="swapads-risk">' . esc_html($riskPct) . '%</span>';
        }
        $row .= '</td>';
        $row .= '<td>' . ($hint !== '' ? esc_html($hint) : '<span class="swapads-muted">&mdash;</span>') . '</td>';
        $row .= '<td>';
        // F202 (2026-07-30): the bulk form wraps the table. We CAN'T use a
        // nested <form> for per-row approve (HTML spec). Instead, emit a
        // link to admin-post.php with the backlink_id as a query arg; the
        // admin-post handler reads from $_REQUEST (GET or POST).
        $approveUrl = add_query_arg(
            [
                'action'      => self::NONCE_ACTION,
                'backlink_id' => $id,
                '_wpnonce'    => wp_create_nonce(self::NONCE_ACTION),
            ],
            admin_url('admin-post.php')
        );
        $row .= '<a href="' . esc_url($approveUrl) . '" class="button button-primary button-small">Approve</a> ';
        // F223: per-row Reject button (GET link, same pattern as approve).
        $rejectUrl = add_query_arg(
            [
                'action'      => self::NONCE_ACTION_REJECT,
                'backlink_id' => $id,
                '_wpnonce'    => wp_create_nonce(self::NONCE_ACTION_REJECT),
            ],
            admin_url('admin-post.php')
        );
        $row .= '<a href="' . esc_url($rejectUrl) . '" class="button button-small" '
              . 'onclick="return confirm(\'Reject backlink #' . $id . '? The offering operator will be notified that you don\'t want this one.\');">Reject</a>';
        $row .= '</td>';
        $row .= '</tr>';
        return $row;
    }

    /**
     * Handle bulk approve submission.
     *
     * F202 (2026-07-30): accepts a list of backlink_ids and posts them
     * in a single round-trip to the server's bulk endpoint. Per-row
     * failures are surfaced in the success/error notice via the redirect
     * query arg `bulk_result`.
     */
    public static function handleApproveBulk(): void
    {
        if (!current_user_can('manage_options')) {
            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
        }
        check_admin_referer(self::NONCE_ACTION_BULK);

        $ids = Request::inputArray('backlink_ids', [], 'post');
        if (!is_array($ids) || empty($ids)) {
            self::redirectError('No backlinks selected');
            return;
        }
        // Normalize: ints, drop non-positive, dedup
        $ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $i): bool => $i > 0)));
        if (count($ids) > self::BULK_MAX) {
            self::redirectError('Bulk limit is ' . self::BULK_MAX);
            return;
        }
        if (empty($ids)) {
            self::redirectError('No valid backlink ids');
            return;
        }
        $client = RestClient::fromOption();
        $resp = $client->post('/v1/backlinks/approve-bulk', ['backlink_ids' => $ids]);
        $err = $client->lastError();
        if ($err !== null) {
            $msg = (string) ($err['error_code'] ?? 'UNKNOWN');
            self::redirectError($msg);
            return;
        }
        $approvedCount = (int) ($resp['count'] ?? 0);
        $failedCount   = (int) (isset($resp['failed']) && is_array($resp['failed']) ? count($resp['failed']) : 0);
        Redirect::to(add_query_arg(
            [
                'page'        => BacklinksHubPage::MENU_SLUG,
                'tab'         => BacklinksHubPage::TAB_PLACE,
                'bulk_ok'     => $approvedCount,
                'bulk_failed' => $failedCount,
            ],
            admin_url('admin.php')
        ));
    }

    /**
     * Handle single reject submission.
     *
     * F223 (2026-07-30): GET link from the per-row Reject button
     * (uses $_REQUEST, not $_POST, same as handleApprove).
     *
     * @since 1.5.2
     */
    public static function handleReject(): void
    {
        if (!current_user_can('manage_options')) {
            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
        }
        check_admin_referer(self::NONCE_ACTION_REJECT);

        $backlinkId = Request::inputInt('backlink_id', 0, 'request');
        if ($backlinkId <= 0) {
            self::redirectError('backlink_id is required');
            return;
        }
        $reason = Request::input('reason', '', 'request');
        $client = RestClient::fromOption();
        $resp = $client->rejectBacklink($backlinkId, $reason);
        if ($client->lastError() !== null) {
            self::redirectError('reject_failed');
            return;
        }
        self::redirectRejected($backlinkId);
    }

    /**
     * Handle bulk reject submission.
     *
     * F223 (2026-07-30): same pattern as handleApproveBulk — single
     * round-trip POST to /v1/backlinks/reject-bulk, then redirect
     * back to Place tab with bulk_rejected=count query arg.
     */
    public static function handleRejectBulk(): void
    {
        if (!current_user_can('manage_options')) {
            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
        }
        check_admin_referer(self::NONCE_ACTION_REJECT_BULK);

        $ids = Request::inputArray('backlink_ids', [], 'post');
        if (!is_array($ids) || empty($ids)) {
            self::redirectError('No backlinks selected');
            return;
        }
        $ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $i): bool => $i > 0)));
        if (empty($ids)) {
            self::redirectError('No valid backlink ids');
            return;
        }
        $reason = Request::input('reason', '', 'post');
        $client = RestClient::fromOption();
        $resp = $client->rejectBacklinkBulk($ids, $reason);
        if ($client->lastError() !== null) {
            self::redirectError('reject_failed');
            return;
        }
        $rejectedCount = (int) ($resp['count'] ?? 0);
        $failedCount   = (int) (isset($resp['failed']) && is_array($resp['failed']) ? count($resp['failed']) : 0);
        Redirect::to(add_query_arg(
            [
                'page'             => BacklinksHubPage::MENU_SLUG,
                'tab'              => BacklinksHubPage::TAB_PLACE,
                'bulk_rejected'    => $rejectedCount,
                'bulk_reject_fail' => $failedCount,
            ],
            admin_url('admin.php')
        ));
    }


    /**
     * Handle approve submission (single row or bulk).
     */
    public static function handleApprove(): void
    {
        if (!current_user_can('manage_options')) {
            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
        }
        check_admin_referer(self::NONCE_ACTION);

        // F202 (2026-07-30): bulk now has a dedicated handler
        // (handleApproveBulk). handleApprove is single-row only.
        // F202 also changed the per-row link to use GET (so we read from
        // $_REQUEST — works for both GET and POST).
        $backlinkId = Request::inputInt('backlink_id', 0, 'request');
        if ($backlinkId <= 0) {
            self::redirectError('backlink_id is required');
            return;
        }
        self::approveOne($backlinkId);
        self::redirectSuccess($backlinkId);
    }

    /**
     * POST to server to approve a single backlink offer.
     */
    private static function approveOne(int $backlinkId): void
    {
        try {
            RestClient::fromOption()->post('/v1/backlinks/approve', ['backlink_id' => $backlinkId]);
        } catch (\Throwable $e) {
            // Swallow — we'll redirect with the last error if any
        }
    }

    private static function redirectSuccess(int $id): void
    {
        Redirect::to(add_query_arg(
            ['page' => self::MENU_SLUG, 'approved' => '1', 'id' => $id],
            admin_url('admin.php')
        ));
    }

    /**
     * F223 (2026-07-30): redirect back to Place tab after a per-row reject.
     *
     * @since 1.5.2
     */
    private static function redirectRejected(int $id): void
    {
        Redirect::to(add_query_arg(
            [
                'page'   => self::MENU_SLUG,
                'tab'    => BacklinksHubPage::TAB_PLACE,
                'rejected' => '1',
                'id'     => $id,
            ],
            admin_url('admin.php')
        ));
    }

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


    /**
     * Hide the sidebar link via CSS instead of remove_submenu_page().
     *
     * F2XX-fix 2026-08-01: WP 7.0's user_can_access_admin_page() iterates
     * $submenu[$parent] looking for the slug; if remove_submenu_page() removed
     * the entry, the cap check returned false and the operator saw
     * 'Sorry, you are not allowed to access this page' on form postbacks.
     *
     * Keeping the entry in $submenu (so the cap check passes) and hiding the
     * visual link via CSS is the correct WP 7.0 pattern.
     */
    public static function hideSubmenuFromSidebar(): void
    {
        // F2XX-fix 2026-08-02: use anchored selector (`$=`) instead of `*=`
        // substring match. The old `href*="page=swapads-client-approval"` would
        // also match `swapads-client-approvals` and `swapads-client-approvals-hub`,
        // causing visual stacking bugs.
        echo '<style>#adminmenu a[href$="page=' . self::MENU_SLUG . '\"]{display:none!important}</style>';
    }
}
