<?php
/**
 * REST Client (client → server).
 *
 * Wraps wp_remote_request with HMAC signing + JSON handling.
 * Uses SwapAds\Shared\Http\RequestBuilder to construct signed headers.
 *
 * @package SwapAds\Client\Api
 * @since   1.0.0
 */

declare(strict_types=1);

namespace SwapAds\Client\Api;

use SwapAds\Client\License\FreemiusAutoActivator;
use SwapAds\Client\License\LicenseManager;
use SwapAds\Client\Security\EncryptedOption;
use SwapAds\Shared\Http\RequestBuilder;

/**
 * Class RestClient.
 *
 * @since 1.0.0
 */
final class RestClient
{
    /**
     * REST namespace as registered on the server side.
     * The base URL is the WP site root (e.g. https://api.swapads.eu);
     * the actual REST endpoints live at {base}/wp-json/{namespace}.
     */
    public const NAMESPACE = 'swapads-server';

    /**
     * Public REST root = base + /wp-json/{namespace}.
     */
    public const REST_ROOT_TEMPLATE = '%s/wp-json/%s';

    // TODO-CLIENT-006 (2026-08-02): modern PHP 8.1 — readonly properties.
    // These are set once in the constructor and never mutated. readonly
    // enforces immutability at the language level (no setters, no accidental
    // mutations mid-request).
    private readonly string $baseUrl;
    private readonly int $timeout;

    /**
     * Construct a RestClient using the configured server URL option.
     *
     * @return self
     */
    public static function fromOption(): self
    {
        $url = (string) get_option('swapads_client_server_url', 'https://api.swapads.eu');
        return new self($url);
    }

    /**
     * @param string $baseUrl Server root (e.g. https://api.swapads.eu)
     * @param int    $timeout Request timeout in seconds.
     */
    public function __construct(string $baseUrl, int $timeout = 10)
    {
        $this->baseUrl = rtrim($baseUrl, '/');
        $this->timeout = $timeout;
    }

    /**
     * GET request with HMAC auth.
     *
     * @param string $route   Route relative to namespace (no leading slash).
     * @param array  $params  Optional query params.
     *
     * @return array<string, mixed> Decoded response body.
     */
    public function get(string $route, array $params = []): array
    {
        if (!empty($params)) {
            $route .= (strpos($route, '?') === false ? '?' : '&') . http_build_query($params);
        }
        return $this->request('GET', $route, '');
    }

    /**
     * POST request with HMAC auth + JSON body.
     *
     * @param string $route
     * @param array  $body
     *
     * @return array<string, mixed>
     */
    public function post(string $route, array $body): array
    {
        return $this->request('POST', $route, (string) json_encode($body));
    }

    /**
     * Send a signed DELETE request.
     *
     * Used by MVP-005 (2026-08-01) for the `Plugin::onDeactivatedPlugin` flow.
     *
     * @param string $route Path relative to base URL.
     *
     * @return array<string, mixed> Decoded JSON response.
     *
     * @since 1.5.7
     */
    public function delete(string $route): array
    {
        return $this->request('DELETE', $route, '');
    }

    /**
     * Register an indexation page URL with the server (MVP-001, 2026-08-01).
     *
     * The URL is stored server-side and HEAD-checked daily via
     * IndexationVerifierCron.
     *
     * @param string $url Indexation page URL.
     *
     * @return array<string, mixed> Server response.
     *
     * @since 1.5.7
     */
    public function registerIndexationPage(string $url): array
    {
        return $this->post('/indexation-page/register', ['url' => $url]);
    }

    /**
     * Fetch the current indexation page verification status from the server.
     *
     * @return array<string, mixed> Server response.
     *
     * @since 1.5.7
     */
    public function getIndexationPageStatus(): array
    {
        return $this->get('/indexation-page/status');
    }

    /**
     * Make a signed request to the server.
     *
     * @param string $method
     * @param string $route  Path relative to base URL.
     * @param string $body   Raw JSON string (POST) or empty (GET).
     *
     * @return array<string, mixed> Decoded JSON response.
     *
     * @throws \RuntimeException on transport errors.
     */
    /**
     * Last error encountered by any REST call on this instance.
     * Shape: {error_code, message, http_status, raw?, request_route?, when}
     * Null when the last call succeeded.
     */
    private ?array $lastError = null;

    /**
     * Get the last error (null if last call succeeded).
     *
     * Use this AFTER a failed REST call to render an admin_notices banner
     * via ClientErrorRenderer.
     *
     * @return array<string, mixed>|null
     */
    public function lastError(): ?array
    {
        return $this->lastError;
    }

    /**
     * Clear the last error (e.g. after rendering it in admin_notices).
     */
    public function clearError(): void
    {
        $this->lastError = null;
    }

    /**
     * Normalized error response. NEVER throws for REST/transport failures —
     * always returns this shape so the caller can display the error inline.
     */
    private function fail(string $errorCode, string $message, int $httpStatus = 0, ?string $raw = null, ?string $route = null): array
    {
        $error = [
            'success'     => false,
            'error_code'  => $errorCode,
            'message'     => $message,
            'http_status' => $httpStatus,
            'when'        => time(),
        ];
        if ($raw !== null) {
            $error['raw'] = $raw;
        }
        if ($route !== null) {
            $error['request_route'] = $route;
        }
        $this->lastError = $error;
        return $error;
    }

    private function request(string $method, string $route, string $body): array
    {
        // Reset error state for each new request.
        $this->lastError = null;

        // CF17.1: gate on (1) license key being set AND (2) server HMAC secret
        // being fetched via /public-key. The two are stored in DIFFERENT
        // option keys:
        //   - LicenseManager::key() returns the Freemius license key
        //     (per-site, from Freemius SDK)
        //   - FreemiusAutoActivator::OPTION_SERVER_SECRET holds the shared
        //     HMAC secret (network-wide, from /v1/public-key)
        // Using the wrong secret for HMAC signing silently fails every
        // signed request.
        if (!LicenseManager::isLicensed()) {
            return $this->fail(
                'LICENSE_NOT_ACTIVATED',
                'License not activated on this site. Activate the Freemius license first.',
                0,
                null,
                $route
            );
        }
        $licenseKey = LicenseManager::key();
        $secret     = EncryptedOption::get(FreemiusAutoActivator::OPTION_SERVER_SECRET, '');
        if ($secret === '') {
            // Try to fetch it now.
            FreemiusAutoActivator::ensureServerSecret();
            $secret = EncryptedOption::get(FreemiusAutoActivator::OPTION_SERVER_SECRET, '');
        }
        if ($secret === '') {
            return $this->fail(
                'SERVER_SECRET_UNAVAILABLE',
                'Could not retrieve HMAC secret from the server (/v1/public-key unreachable). Server may be down or blocked.',
                0,
                null,
                $route
            );
        }
        // Sign with the FULL route (including namespace) so the server's
        // $request->get_route() returns the same string we hashed.
        // Server's get_route() returns '/{namespace}/{version}/{resource}',
        // e.g. '/swapads-server/v1/license/activate' — it does NOT
        // include the query string, so we must strip the `?…` suffix from
        // the canonical input before signing. (The URL itself still
        // carries the query, but only the path part is hashed.)
        // See F-DIAG-2026-07-30: /v1/backlinks/match?limit=5 was
        // mismatching because client signed the query string but server
        // dropped it.
        $pathForSigning = $route;
        $qPos = strpos($pathForSigning, '?');
        if ($qPos !== false) {
            $pathForSigning = substr($pathForSigning, 0, $qPos);
        }
        $fullRoute = '/' . self::NAMESPACE . $pathForSigning;
        $headers   = (new RequestBuilder($licenseKey, $secret))->buildHeaders($method, $fullRoute, $body);
        // Prepend /wp-json/{NAMESPACE} so the route lands on the REST API,
        // not on the WP 404 handler.
        $restRoot = sprintf(self::REST_ROOT_TEMPLATE, $this->baseUrl, self::NAMESPACE);
        $url      = $restRoot . $route;

        $requestHeaders = [
            'Content-Type' => 'application/json',
            'Accept'       => 'application/json',
        ];
        foreach ($headers as $k => $v) {
            $requestHeaders[$k] = (string) $v;
        }

        $args = [
            'method'  => strtoupper($method),
            'headers' => $requestHeaders,
            'body'    => $body,
            'timeout' => $this->timeout,
        ];

        $response = wp_remote_request($url, $args);
        if (is_wp_error($response)) {
            return $this->fail(
                'NETWORK_ERROR',
                'Could not reach the SwapAds server: ' . $response->get_error_message(),
                0,
                null,
                $route
            );
        }
        $code = (int) wp_remote_retrieve_response_code($response);
        $raw  = (string) wp_remote_retrieve_body($response);
        $data = json_decode($raw, true);
        if (!is_array($data)) {
            // Non-JSON response (e.g. 404 HTML from a misconfigured URL).
            // Surface the HTTP status + a snippet of raw body so the operator
            // can see what the server actually returned.
            $snippet = strlen($raw) > 200 ? substr($raw, 0, 200) . '...' : $raw;
            return $this->fail(
                'INVALID_RESPONSE',
                sprintf('Server returned HTTP %d but the body was not JSON. First 200 chars: %s', $code, $snippet),
                $code,
                $raw,
                $route
            );
        }
        $data['http_status'] = $code;

        // If the server returned an error envelope, persist it as lastError.
        if (isset($data['success']) && $data['success'] === false) {
            $this->lastError = [
                'success'        => false,
                'error_code'     => (string) ($data['error_code'] ?? 'UNKNOWN_ERROR'),
                'message'        => (string) ($data['message'] ?? 'Server returned an error without a message.'),
                'http_status'    => $code,
                'request_route'  => $route,
                'when'           => time(),
            ];
        }

        return $data;
    }

    // ============================================================================
    // AD CREDITS (F-AD-CREDITS, operator decision 2026-07-30)
    // ============================================================================

    /**
     * Record an ad IMPRESSION on this client's site (a partner's ad was displayed).
     * Operator earns 1 ad credit.
     *
     * @param string $adId           Server-issued ad identifier.
     * @param string $idempotencyKey Client-generated unique key (UUID recommended).
     * @param bool   $viewabilityFlag True if viewability check passed.
     *
     * @return array<string, mixed> Server response with balance snapshot.
     *
     * @since 1.4.0
     */
    public function earnAdOnImpression(string $adId, string $idempotencyKey, bool $viewabilityFlag = false): array
    {
        return $this->post('/v1/credits/earn-ad', [
            'ad_id'            => $adId,
            'event'            => 'impression',
            'idempotency_key'  => $idempotencyKey,
            'viewability_flag' => $viewabilityFlag,
        ]);
    }

    /**
     * Record an ad CLICK on this client's site (visitor clicked partner's ad).
     * Operator earns 5 ad credits.
     *
     * @param string $adId
     * @param string $idempotencyKey
     * @param bool   $viewabilityFlag
     *
     * @return array<string, mixed>
     *
     * @since 1.4.0
     */
    public function earnAdOnClick(string $adId, string $idempotencyKey, bool $viewabilityFlag = false): array
    {
        return $this->post('/v1/credits/earn-ad', [
            'ad_id'            => $adId,
            'event'            => 'click',
            'idempotency_key'  => $idempotencyKey,
            'viewability_flag' => $viewabilityFlag,
        ]);
    }

    /**
     * Record that THIS client's ad was displayed on a partner's site (impression).
     * Operator spends 1 ad credit (balance may go negative — grace overserving).
     *
     * @param string $adId
     * @param string $idempotencyKey
     *
     * @return array<string, mixed>
     *
     * @since 1.4.0
     */
    public function spendAdOnImpression(string $adId, string $idempotencyKey): array
    {
        return $this->post('/v1/credits/spend-ad', [
            'ad_id'           => $adId,
            'event'           => 'impression',
            'idempotency_key' => $idempotencyKey,
        ]);
    }

    /**
     * Record that THIS client's ad received a click on a partner's site.
     * Operator spends 5 ad credits.
     *
     * @param string $adId
     * @param string $idempotencyKey
     *
     * @return array<string, mixed>
     *
     * @since 1.4.0
     */
    public function spendAdOnClick(string $adId, string $idempotencyKey): array
    {
        return $this->post('/v1/credits/spend-ad', [
            'ad_id'           => $adId,
            'event'           => 'click',
            'idempotency_key' => $idempotencyKey,
        ]);
    }

    /**
     * Read the current credit balance (returns split {backlinks: {...}, ads: {...}}).
     *
     * @return array<string, mixed>
     *
     * @since 1.4.0
     */
    /**
     * F200: List operator's own backlinks with placement stats.
     *
     * @param bool $includeRemoved Include soft-deleted rows.
     * @param int  $limit          Max results (clamped server-side at 500).
     * @return array<string, mixed> Response payload. Shape:
     *                              { success: bool, backlinks: array<int, array>, count: int }
     *                              Empty array on transport error (check lastError()).
     */
    public function listBacklinks(bool $includeRemoved = false, int $limit = 100): array
    {
        $q = '?limit=' . max(1, min(500, $limit));
        if ($includeRemoved) {
            $q .= '&include_removed=true';
        }
        return $this->get('/v1/backlinks/list' . $q);
    }

    /**
     * F121 (2026-07-31): list source-check status for the operator's
     * backlinks. Returns [{id, source_url, status, last_checked_at,
     * last_source_check_status, last_check_age_hours}, ...].
     *
     * @param string $status Optional status filter (active/removed).
     * @param int    $limit  Max rows (1..500, default 100).
     *
     * @return array<int|string, mixed>
     */
    public function listSourceChecks(string $status = '', int $limit = 100): array
    {
        $q = '?limit=' . max(1, min(500, $limit));
        if ($status !== '') {
            $q .= '&status=' . rawurlencode($status);
        }
        return $this->get('/v1/backlinks/source-check' . $q);
    }

    /**
     * F121 (2026-07-31): manually trigger a source-check for one or
     * more of the operator's backlinks.
     *
     * Single round-trip — same pattern as approve-bulk / reject-bulk.
     *
     * @param array<int, int> $ids  Backlink IDs to recheck (1..50).
     *
     * @return array<int|string, mixed> {success, results: [{id, result, http_status, refunds}], count}
     */
    public function recheckSource(array $ids): array
    {
        $ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $i): bool => $i > 0)));
        if (empty($ids)) {
            return ['success' => false, 'error_code' => 'INVALID_INPUT'];
        }
        if (count($ids) > 50) {
            $ids = array_slice($ids, 0, 50);
        }
        return $this->post('/v1/backlinks/source-check/recheck', ['backlink_ids' => $ids]);
    }

    /**
     * F121 (2026-07-31): trigger source-check for ALL of the operator's
     * active offers.
     *
     * @return array<int|string, mixed>
     */
    public function recheckAllSources(): array
        {
        return $this->post('/v1/backlinks/source-check/recheck', ['all' => true]);
        }

        /**
             * F230 (2026-07-31): list the audit log for the current operator.
             *
             * Returns the most recent N events for this site. Filterable by action
             * (e.g. "backlink.approve", "approval.reject", "source_check.404") and
             * entity_type (e.g. "backlink", "approval", "source_check").
             *
             * @param string $action     Optional exact-match action filter.
             * @param string $entityType Optional exact-match entity_type filter.
             * @param int    $limit      Max rows (1..500, default 50).
             * @param int    $offset     Pagination offset (default 0).
             *
             * @return array<int|string, mixed>
             */
            public function listAuditLog(
        string $action = '',
        string $entityType = '',
        int $limit = 50,
        int $offset = 0
    ): array {
        $limit = max(1, min(500, $limit));
        $offset = max(0, $offset);
        $q = '?limit=' . $limit . '&offset=' . $offset;
        if ($action !== '') {
            $q .= '&action=' . rawurlencode($action);
        }
        if ($entityType !== '') {
            $q .= '&entity_type=' . rawurlencode($entityType);
        }
        return $this->get('/v1/audit-log' . $q);
    }

    /**
     * F220 (2026-07-30): list approvals of MY backlinks.
     *
     * Operator A offers; Operator B approves. A wants to see who
     * approved what. Returns a list of {approval_id, backlink_id,
     * status, approved_at, approver_site_url, approver_site_domain,
     * source_url, anchor_text, ...}.
     *
     * @param int      $backlinkId Filter to one of A's backlinks (0 = all).
     * @param string   $status     '' = any; 'pending'|'approved'|'placed'|'rejected'.
     * @param int      $limit      Max rows (1..500, default 100).
     *
     * @return array<int|string, mixed> Shape {success, items, count, filter}.
     */
    public function listBacklinkApprovals(int $backlinkId = 0, string $status = '', int $limit = 100): array
    {
        $limit = max(1, min(500, $limit));
        $q = '?limit=' . $limit;
        if ($backlinkId > 0) {
            $q .= '&backlink_id=' . $backlinkId;
        }
        if ($status !== '') {
            $allowed = ['pending', 'approved', 'placed', 'rejected'];
            if (in_array($status, $allowed, true)) {
                $q .= '&status=' . rawurlencode($status);
            }
        }
        return $this->get('/v1/backlinks/approvals' . $q);
    }

    /**
     * F223 (2026-07-30): reject a single backlink offer.
     *
     * Sets the approval row's status=rejected + rejected_at=NOW + optional
     * reason. Does NOT spend credits (operators don't pay for rejecting).
     *
     * @param int                  $backlinkId Backlink offer ID.
     * @param string               $reason     Optional human-readable reason (max 500 chars).
     *
     * @return array<int|string, mixed> Shape {success, rejection_id, backlink_id, reason}.
     */
    public function rejectBacklink(int $backlinkId, string $reason = ''): array
    {
        $body = ['backlink_id' => max(1, $backlinkId)];
        if ($reason !== '') {
            $body['reason'] = mb_substr($reason, 0, 500);
        }
        return $this->post('/v1/backlinks/reject', $body);
    }

    /**
     * F223 (2026-07-30): bulk reject multiple backlink offers.
     *
     * Single round-trip — same pattern as approve-bulk. Up to 50 ids.
     * Returns {success, rejected[], failed[], count, reason}.
     *
     * @param array<int, int> $ids   Backlink IDs to reject.
     * @param string          $reason Optional reason (max 500 chars).
     *
     * @return array<int|string, mixed>
     */
    public function rejectBacklinkBulk(array $ids, string $reason = ''): array
    {
        $body = ['backlink_ids' => array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $i): bool => $i > 0)))];
        if (count($body['backlink_ids']) > 50) {
            $body['backlink_ids'] = array_slice($body['backlink_ids'], 0, 50);
        }
        if ($reason !== '') {
            $body['reason'] = mb_substr($reason, 0, 500);
        }
        return $this->post('/v1/backlinks/reject-bulk', $body);
    }

    /**
     * F200: Update a backlink (whitelist of mutable fields).
     *
     * @param int                  $id     Backlink ID.
     * @param array<string, mixed> $fields Mutable fields (anchor_text, hint,
     *                                       rel_attribute, max_external_on_source,
     *                                       status, target_audience).
     * @return array<string, mixed> Response payload. Shape:
     *                              { success: bool, backlink: array }
     *                              Empty array on transport error (check lastError()).
     */
    public function updateBacklink(int $id, array $fields): array
    {
        return $this->post('/v1/backlinks/update', [
            'id'     => $id,
            'fields' => $fields,
        ]);
    }

    /**
     * F200: Soft-delete a backlink (status='removed').
     *
     * @param int $id Backlink ID.
     * @return array<string, mixed> Response payload. Shape:
     *                              { success: bool, id: int, status: string }
     *                              Empty array on transport error (check lastError()).
     */
    public function deleteBacklink(int $id): array
    {
        return $this->post('/v1/backlinks/delete', [
            'id' => $id,
        ]);
    }

    public function creditsBalance(): array
    {
        return $this->get('/v1/credits/balance');
    }
}
