<?php
/**
 * Client-Side Error Renderer (F198).
 *
 * Renders inline `admin_notices` HTML for the operator when a client-side
 * REST call fails. Every caller (BacklinkCreatePage, BacklinkApprovalPage,
 * OnboardingWizardPage, OperatorDashboardPage, SettingsPage) should call
 * ClientErrorRenderer::render($context, $error) AFTER a failed REST call
 * so the operator sees the actual failure reason instead of a silent dead
 * click.
 *
 * Design choices:
 *   - Inline notice on the caller page (NOT a global banner). Operator
 *     retains context: "this BACKLINK OFFER failed because of X".
 *   - One-line summary + collapsible details (error_code, http_status,
 *     request_route, raw response snippet).
 *   - Each call uses a stable $context string so we don't render the
 *     same error twice (the renderer returns '' after first call until
 *     clearFor($context) is called).
 *   - i18n via standard __() / esc_html__().
 *   - Renders to a string; callers `echo` it directly. No global side
 *     effects (no admin_notices action) — keeps the renderer testable.
 *
 * @package SwapAds\Client\Admin
 * @since   1.5.0
 */

declare(strict_types=1);

namespace SwapAds\Client\Admin;

final class ClientErrorRenderer
{
    /**
     * Per-request rendered contexts. Prevents the same error from being
     * rendered twice if multiple pages call render() in the same load.
     *
     * @var array<string, true>
     */
    private static array $rendered = [];

    /**
     * Render an error as HTML for an inline admin notice.
     *
     * Returns '' if:
     *   - $error is null
     *   - $error has no error_code or message
     *   - $context was already rendered in this request
     *
     * @param string               $context Stable identifier for this call site.
     *                                            e.g. 'swapads_client_backlink_create'.
     * @param array<string, mixed>|null $error  Normalized error array (from RestClient::lastError()).
     * @param string|null          $actionLabel Human label for the action that failed.
     *                                            e.g. 'create backlink offer'.
     *
     * @return string HTML (empty if nothing to render).
     */
    public static function render(string $context, ?array $error, ?string $actionLabel = null): string
    {
        if ($error === null) {
            return '';
        }
        if (!isset($error['error_code']) && !isset($error['message'])) {
            return '';
        }
        if (isset(self::$rendered[$context])) {
            return '';
        }
        self::$rendered[$context] = true;

        $errorCode  = (string) ($error['error_code'] ?? 'UNKNOWN_ERROR');
        $message    = (string) ($error['message'] ?? 'Server returned an error without a message.');
        $httpStatus = isset($error['http_status']) ? (int) $error['http_status'] : 0;
        $route      = isset($error['request_route']) ? (string) $error['request_route'] : '';
        $raw        = isset($error['raw']) ? (string) $error['raw'] : '';

        // Translate known error codes into operator-friendly hints.
        $hint = self::hintFor($errorCode, $message);

        $actionPhrase = $actionLabel !== null && $actionLabel !== ''
            ? sprintf(' to %s', esc_html($actionLabel))
            : '';

        // F2XX (2026-08-01): RECHECK_SUMMARY is a SUCCESS message with bad
        // metadata (it's stashed via the error transient for routing). Render
        // it as a green notice, not a red banner.
        if ($errorCode === 'RECHECK_SUMMARY') {
            $out  = '<div class="notice notice-success swapads-client-summary-notice" data-swapads-context="' . esc_attr($context) . '" style="border-left-color:#46b450;padding:12px 14px;margin:12px 0;">';
            $out .= '<p style="margin:0 0 8px 0;"><strong>' . esc_html__('SwapAds: recheck completed', 'swapads-client') . '</strong></p>';
            $out .= '<p role="status" aria-live="polite" style="margin:0 0 0 0;">' . esc_html($message) . '</p>';
            $out .= '</div>';
            return $out;
        }

        // Build the notice HTML.
        $out  = '<div class="notice notice-error swapads-client-error-notice" data-swapads-context="' . esc_attr($context) . '" style="border-left-color:#dc3232;padding:12px 14px;margin:12px 0;">';
        $out .= '<p style="margin:0 0 8px 0;"><strong>' . esc_html(sprintf(__('SwapAds: failed%s', 'swapads-client'), $actionPhrase)) . '</strong></p>';
        $out .= '<p role="status" aria-live="assertive" style="margin:0 0 8px 0;">' . esc_html($message) . '</p>';
        if ($hint !== '') {
            $out .= '<p style="margin:0 0 8px 0;color:#666;font-size:13px;"><em>' . esc_html($hint) . '</em></p>';
        }

        // Collapsible details.
        $detailsLabel = esc_html__('Technical details', 'swapads-client');
        $detailId = 'swapads-err-' . md5($context);
        $out .= '<details style="margin-top:6px;"><summary style="cursor:pointer;color:#2271b1;">' . $detailsLabel . '</summary>';
        $out .= '<div style="margin-top:8px;font-family:monospace;font-size:12px;background:#f6f7f7;padding:8px;border-radius:3px;">';
        $out .= '<div><strong>error_code:</strong> ' . esc_html($errorCode) . '</div>';
        if ($httpStatus > 0) {
            $out .= '<div><strong>http_status:</strong> ' . esc_html((string) $httpStatus) . '</div>';
        }
        if ($route !== '') {
            $out .= '<div><strong>request_route:</strong> ' . esc_html($route) . '</div>';
        }
        if ($raw !== '') {
            $snippet = strlen($raw) > 400 ? substr($raw, 0, 400) . '...' : $raw;
            $out .= '<div><strong>raw_response:</strong> <pre style="white-space:pre-wrap;margin:4px 0 0 0;">' . esc_html($snippet) . '</pre></div>';
        }
        $out .= '</div></details>';

        $out .= '</div>';
        return $out;
    }

    /**
     * Operator-friendly hint per known error code. Keeps the message
     * field clean (server's technical message) and adds a plain-language
     * next step.
     */
    private static function hintFor(string $errorCode, string $message): string
    {
        switch ($errorCode) {
            case 'LICENSE_NOT_ACTIVATED':
                return __('Fix: open SwapAds → Settings and complete the Freemius opt-in.', 'swapads-client');
            case 'SERVER_SECRET_UNAVAILABLE':
                return __('Fix: the SwapAds server is unreachable from this site. Check that your SwapAds server is online, and that this site can reach it.', 'swapads-client');
            case 'NETWORK_ERROR':
                return __('Fix: check that this site can reach the configured SwapAds server. A firewall, DNS issue, or offline server may be blocking the request.', 'swapads-client');
            case 'INVALID_RESPONSE':
                return __('Fix: the SwapAds server returned a non-JSON response (often a 404 HTML page). Verify the configured server URL points to the WordPress site root (the REST API lives at /wp-json/swapads-server/v1/...).', 'swapads-client');
            case 'HMAC_INVALID':
            case 'HMAC_MISSING_HEADERS':
            case 'HMAC_BAD_TIMESTAMP':
            case 'HMAC_TIMESTAMP_OUT_OF_WINDOW':
            case 'HMAC_REPLAY_DETECTED':
                return __('Fix: the HMAC handshake failed. The license key and HMAC secret are out of sync. Try SwapAds → Settings → "Resync license now" to refetch the server secret.', 'swapads-client');
            case 'LICENSE_UNKNOWN':
                return __('Fix: the server has no record of this license. The license was just activated locally — try again in a few seconds, or check SwapAds → Settings → Activation status.', 'swapads-client');
            case 'LICENSE_NOT_FOUND':
                return __('Fix: server has no license for this site. Try SwapAds → Settings → Resync to re-register.', 'swapads-client');
            case 'OFFER_REJECTED':
                return __('Fix: the offer was rejected by the server (URL format, anchor format, or status). Review the backlink offer constraints in the form.', 'swapads-client');
            case 'PAYLOAD_TOO_LARGE':
                return __('Fix: the request exceeded 64 KB. Try a shorter hint or fewer fields.', 'swapads-client');
            case 'RATE_LIMIT_EXCEEDED':
                return __('Fix: too many requests in a short window. Wait a minute and try again.', 'swapads-client');
            case 'DAILY_CAP_EXCEEDED':
                return __('Fix: you hit the daily ad-earning cap (10,000 credits/site/day). Try again tomorrow, or ask your account manager to raise the cap.', 'swapads-client');
            case 'INVALID_VIEWABILITY_PROOF':
                return __('Fix: the impression did not pass the viewability check (no JavaScript, headless browser, or bot detected). Set viewability_flag=true on legitimate traffic only.', 'swapads-client');
            case 'INVALID_LICENSE':
                return __('Fix: your license is suspended, expired, or revoked. Check SwapAds → Settings → Activation status, or contact support.', 'swapads-client');
            case 'FRAUD_DETECTED':
                return __('Fix: your account triggered a fraud rule. Review the source URL + anchor text for compliance, then contact support.', 'swapads-client');
            case 'CIRCUIT_OPEN':
                return __('Fix: the server is temporarily in circuit-breaker mode (too many recent failures). Try again in 60 seconds.', 'swapads-client');
            case 'UNAUTHORIZED':
                return __('Fix: your session expired. Reload the page and try again.', 'swapads-client');
            case 'BALANCE_REQUIRED':
                return __('Fix: your credit balance is 0. Earn credits by placing partner links, or accept a backlink offer to earn.', 'swapads-client');
            case 'NOT_FOUND':
                return __('Fix: the resource does not exist. Refresh the page and try again.', 'swapads-client');
            case 'RECIPROCAL_RISK_REJECTED':
                return __('Fix: this offer would create a reciprocal backlink loop. Pick a different partner to keep SEO healthy.', 'swapads-client');
            }

        // Generic catch-all: if HTTP status is 0 it's transport, 5xx is server, 4xx is client.
        if ($message === '' || $message === '0') {
            return __('No additional context available.', 'swapads-client');
        }
        return '';
    }

    /**
     * Clear the rendered flag for a context. Useful when the same page
     * renders, processes a form, and re-renders (so the error can show
     * again on the next page load).
     *
     * Note: per-request static state is intentionally NOT persisted across
     * requests. Callers that want a sticky error should persist it in
     * a transient + clear via SettingsPage action.
     */
    public static function clearFor(string $context): void
    {
        unset(self::$rendered[$context]);
    }

    /**
     * Reset ALL rendered flags (for test isolation).
     */
    public static function resetAll(): void
    {
        self::$rendered = [];
    }
}