<?php
/**
 * SwapAds Client — StatisticsFlushEndpoint (F238).
 *
 * Per F238 design (locked 2026-07-31):
 * - Server's AdaptivePollCron polls this endpoint on adaptive schedule
 * - HMAC-signed request validates server identity (preventing random
 *   3rd parties from triggering drains)
 * - Returns the local pending_statistics queue
 * - Listens for ACK to clear events after server-side processing
 *
 * Auth (flush + ack):
 * - X-SwapAds-License-Id header (matches the operator's license)
 * - X-SwapAds-Timestamp header (must be within 5 min of now)
 * - X-SwapAds-Signature header (HMAC-SHA256 of "flush:<license_id>:<ts>"
 *   using the license_key as the secret)
 *
 * TODO-CLIENT-API-002 + TODO-CLIENT-API-003 (2026-08-01):
 * - handleEnqueue() now:
 *   * Origin allowlist: Origin/Referer must match home_url()
 *   * Per-IP rate limit: 10 events/min via set_transient
 *   * backlink_id must be in local offered_backlinks
 *   * partner_id must match what operator declared
 *   * visitor_ip + visitor_ua are HASHED, not stored raw
 *   * Client-supplied visitor_ip/visitor_ua are stripped from body
 *
 * Naming-convention-locked (avoid tracker/ad/beacon trigger words).
 *
 * @since 1.5.3
 * @since 1.5.9 CRIT hardening (TODO-CLIENT-API-002/003)
 */

declare(strict_types=1);

namespace SwapAds\Client\Statistics;

use SwapAds\Client\License\LicenseManager;

if (!defined('ABSPATH')) { /* test mode: skip WordPress bootstrap guard */ }

final class StatisticsFlushEndpoint
{
    public const AJAX_ACTION_FLUSH   = 'swapads_statistics_flush';
    public const AJAX_ACTION_ACK     = 'swapads_statistics_ack';
    public const AJAX_ACTION_ENQUEUE = 'swapads_statistics_enqueue';

    public const HMAC_WINDOW_SECONDS = 300;  // 5 minutes

    /**
     * Option key holding the operator's offered-backlinks list.
     * Keyed by backlink_id; each entry has id + partner_id.
     * Maintained by BacklinkCreatePage when an operator offers a backlink.
     */
    public const OPTION_OFFERED_BACKLINKS = 'swapads_client_offered_backlinks';

    /**
     * Per-IP rate limit (events per minute).
     */
    public const RATE_LIMIT_PER_MINUTE = 10;

    /**
     * Register admin-ajax handlers.
     */
    public static function register(): void
    {
        // Flush + ACK are server-initiated (no logged-in user needed; auth
        // via HMAC signature).
        add_action('wp_ajax_nopriv_' . self::AJAX_ACTION_FLUSH, [self::class, 'handleFlush']);
        add_action('wp_ajax_' . self::AJAX_ACTION_ACK, [self::class, 'handleAck']);

        // Enqueue is JS-initiated from public pages. Visitor does NOT need
        // to be logged in.
        // TODO-CLIENT-API-002 (2026-08-01): was 'wp_ajax_nopriv_' only.
        // We register BOTH so logged-in visitors also work; the origin /
        // rate-limit / backlink-ownership checks below prevent forgery.
        add_action('wp_ajax_nopriv_' . self::AJAX_ACTION_ENQUEUE, [self::class, 'handleEnqueue']);
        add_action('wp_ajax_' . self::AJAX_ACTION_ENQUEUE, [self::class, 'handleEnqueue']);
    }

    /**
     * Handle server's HMAC-signed flush request.
     * Returns the queued events + (optionally) bot-signature deltas.
     */
    public static function handleFlush(): void
    {
        $auth = self::validateServerRequest();
        if ($auth !== true) {
            status_header(401);
            wp_send_json(['error' => 'unauthorized', 'detail' => $auth], 401);
            self::maybeExit();
            return;
        }

        $events = StatisticsQueue::drain();
        // Re-enqueue: we don't drain on read; we wait for ACK. (Safer:
        // if server crashes between read + ACK, we don't lose events.)
        foreach ($events as $event) {
            StatisticsQueue::enqueue($event);
        }

        wp_send_json([
            'events'    => $events,
            'count'     => count($events),
            'server_ts' => time(),
        ], 200);
        self::maybeExit();
    }

    /**
     * Handle server's ACK after it has stored the events.
     * Body: { license_id: int, last_event_ts: int, events_stored: int }
     * Removes events with enqueued_at <= last_event_ts.
     */
    public static function handleAck(): void
    {
        $auth = self::validateServerRequest();
        if ($auth !== true) {
            status_header(401);
            wp_send_json(['error' => 'unauthorized'], 401);
            self::maybeExit();
            return;
        }
        $raw = file_get_contents('php://input');
        $body = is_string($raw) ? (array) json_decode($raw, true) : [];
        $lastEventTs = (int) ($body['last_event_ts'] ?? 0);
        if ($lastEventTs <= 0) {
            status_header(400);
            wp_send_json(['error' => 'missing_last_event_ts'], 400);
            self::maybeExit();
            return;
        }
        $drained = StatisticsQueue::drainOlderThan($lastEventTs);
        wp_send_json([
            'success'         => true,
            'drained_count'   => count($drained),
            'remaining_count' => StatisticsQueue::count(),
        ], 200);
        self::maybeExit();
    }

    /**
     * Validate the HMAC signature from the polling server.
     *
     * @return bool|string true on success, error string on failure.
     */
    private static function validateServerRequest()
    {
        $licenseId = isset($_SERVER['HTTP_X_SWAPADS_LICENSE_ID'])
            ? (int) $_SERVER['HTTP_X_SWAPADS_LICENSE_ID'] : 0;
        $timestamp = isset($_SERVER['HTTP_X_SWAPADS_TIMESTAMP'])
            ? (string) $_SERVER['HTTP_X_SWAPADS_TIMESTAMP'] : '';
        $signature = isset($_SERVER['HTTP_X_SWAPADS_SIGNATURE'])
            ? (string) $_SERVER['HTTP_X_SWAPADS_SIGNATURE'] : '';
        if ($licenseId <= 0 || $timestamp === '' || $signature === '') {
            return 'missing_headers';
        }
        // Window check.
        $ts = (int) $timestamp;
        if (abs(time() - $ts) > self::HMAC_WINDOW_SECONDS) {
            return 'timestamp_out_of_window';
        }
        // Get our license_key (HMAC secret).
        $licenseKey = LicenseManager::key();
        if (!is_string($licenseKey) || $licenseKey === '') {
            return 'license_not_activated';
        }
        // Verify signature.
        $expected = hash_hmac('sha256', "flush:{$licenseId}:{$timestamp}", $licenseKey);
        if (!hash_equals($expected, $signature)) {
            return 'bad_signature';
        }
        return true;
    }

    /**
     * Handle JS-initiated enqueue from the public-facing backlinks-
     * statistics.js bundle. Visitor click on .swapads-backlink anchor
     * fires this.
     *
     * TODO-CLIENT-API-002 + TODO-CLIENT-API-003 (2026-08-01):
     * Hardened against forgery + IP-based flooding + DoS amplification.
     *
     * Body: { backlink_id:int, partner_id:int, event_type:string,
     *         ts_ms:int, page_url?:string, swapads_sid?:string }
     *
     * visitor_ip + visitor_ua are derived from the SERVER (PHP $_SERVER),
     * hashed with wp_salt('auth'), and stored as 32-char SHA-256 prefixes.
     * Client-supplied visitor_ip/visitor_ua in the body are STRIPPED.
     *
     * @since 1.5.3
     * @since 1.5.9 hardening
     */
    public static function handleEnqueue(): void
    {
        // 1. Origin allowlist: Origin/Referer must match this site.
        $origin = (string) ($_SERVER['HTTP_ORIGIN'] ?? $_SERVER['HTTP_REFERER'] ?? '');
        $siteUrl = (string) home_url();
        if ($origin !== '' && $siteUrl !== '' && strpos($origin, $siteUrl) !== 0) {
            status_header(403);
            wp_send_json(['error' => 'forbidden_origin'], 403);
            self::maybeExit();
            return;
        }

        $raw = file_get_contents('php://input');
        $body = is_string($raw) ? (array) json_decode($raw, true) : [];
        $backlinkId = (int) ($body['backlink_id'] ?? 0);
        $partnerId  = (int) ($body['partner_id'] ?? 0);
        $eventType  = (string) ($body['event_type'] ?? '');

        // 2. Validate event_type BEFORE rate-limiting (cheap early reject).
        if (!in_array($eventType, ['view', 'click'], true)) {
            status_header(400);
            wp_send_json(['error' => 'invalid_event_type'], 400);
            self::maybeExit();
            return;
        }
        if ($backlinkId <= 0 || $partnerId <= 0) {
            status_header(400);
            wp_send_json(['error' => 'missing_ids'], 400);
            self::maybeExit();
            return;
        }

        // 3. Backlink ownership: backlink_id must be offered BY this site,
        //    and partner_id must match what the operator declared.
        if (!self::backlinkBelongsToThisSite($backlinkId, $partnerId)) {
            status_header(403);
            wp_send_json(['error' => 'unknown_backlink'], 403);
            self::maybeExit();
            return;
        }

        // 4. Per-IP rate limit: RATE_LIMIT_PER_MINUTE events/minute.
        $ip = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
        $ipKey = 'swapads_stat_rl:' . md5($ip . '|' . gmdate('YmdHi'));
        $count = (int) get_transient($ipKey);
        if ($count >= self::RATE_LIMIT_PER_MINUTE) {
            status_header(429);
            wp_send_json(['error' => 'rate_limited'], 429);
            self::maybeExit();
            return;
        }
        set_transient($ipKey, $count + 1, 120);

        // 5. HASH the IP + UA (not raw). Strip any client-supplied
        //    visitor_ip / visitor_ua to prevent spoofing.
        unset($body['visitor_ip'], $body['visitor_ua']);
        $body['visitor_ip_hash'] = substr(
            hash('sha256', $ip . wp_salt('auth')),
            0,
            32
        );
        $body['visitor_ua_hash'] = substr(
            hash('sha256', (string) ($_SERVER['HTTP_USER_AGENT'] ?? '') . wp_salt('auth')),
            0,
            32
        );

        $ok = StatisticsQueue::enqueue($body);
        if (!$ok) {
            status_header(400);
            wp_send_json(['error' => 'invalid_event'], 400);
            self::maybeExit();
            return;
        }
        wp_send_json(['success' => true, 'queue_size' => StatisticsQueue::count()], 200);
        self::maybeExit();
    }

    /**
     * Verify that the given backlink_id is offered by THIS site AND the
     * partner_id matches what the operator declared.
     *
     * Storage shape in wp_options('swapads_client_offered_backlinks'):
     *   [{ id: int, partner_id: int, ... }, ...]
     *
     * @param int $backlinkId Backlink id from the request.
     * @param int $partnerId  Partner id from the request.
     *
     * @return bool True if owned + partner matches.
     *
     * @since 1.5.9
     */
    private static function backlinkBelongsToThisSite(int $backlinkId, int $partnerId): bool
    {
        $offered = get_option(self::OPTION_OFFERED_BACKLINKS, []);
        if (!is_array($offered) || empty($offered)) {
            return false;
        }
        foreach ($offered as $row) {
            if (!is_array($row)) {
                continue;
            }
            if ((int) ($row['id'] ?? 0) === $backlinkId
                && (int) ($row['partner_id'] ?? 0) === $partnerId) {
                return true;
            }
        }
        return false;
    }

    /**
     * Conditionally call exit() — skipped in test mode so PHPUnit can
     * assert on the captured wp_send_json() payload.
     *
     * In production (real WP request), the constant SWAPADS_TESTING_EXIT_SKIP
     * is NOT defined and the process exits after the response is sent.
     *
     * @since 1.5.9
     */
    private static function maybeExit(): void
    {
        if (!defined('SWAPADS_TESTING_EXIT_SKIP')) {
            exit;
        }
    }

}