<?php
/**
 * F118 — License Webhook Receiver (client side).
 *
 * Receives POST {site_url}/wp-json/swapads-client/v1/license/status from the
 * server when an admin pauses/activates/deletes the license via the Clients
 * submenu. Validates the HMAC signature, then mutates local license options
 * to match the server state.
 *
 * Why this is needed:
 *   - When server pauses a license, the client must stop calling the server.
 *   - When server deletes a license, the client must clear all credentials
 *     so the operator can re-activate with a different server later.
 *   - Without F118, a paused client keeps polling the server indefinitely.
 *
 * Auth:
 *   - X-SwapAds-License-Id  : the license id (matches local OPTION_KEY's id)
 *   - X-SwapAds-Timestamp   : UNIX seconds; must be within 5 min
 *   - X-SwapAds-Signature   : HMAC-SHA256 of "license_status:<license_id>:<ts>:<status>:<license_key>"
 *                             using the license_key as the secret.
 *                             (TODO-CLIENT-API-004 2026-08-01: license_key now bound into
 *                              payload so body-modification attacks are detected.)
 *   - X-SwapAds-Source      : should be "swapads-server"
 *
 * Body (JSON):
 *   {
 *     license_key: string,
 *     status: "active"|"paused"|"deleted",
 *     source: "swapads-server-f118"
 *   }
 *
 * @package SwapAds\Client\License
 * @since   1.5.6
 */

declare(strict_types=1);

namespace SwapAds\Client\License;

use SwapAds\Client\License\FreemiusAutoActivator;
use SwapAds\Client\Security\EncryptedOption;
use SwapAds\Client\Statistics\StatisticsQueue;
use WP_REST_Request;
use WP_REST_Response;

/**
 * Class LicenseWebhookReceiver.
 *
 * @since 1.5.6
 */
final class LicenseWebhookReceiver
{
    /**
     * REST route path (relative to swapads-client/v1).
     */
    public const ROUTE_PATH = '/license/status';

    /**
     * HMAC window — request must be within ±5 min of server time.
     */
    public const HMAC_WINDOW_SECONDS = 300;

    /**
     * Header names (also used as $_SERVER keys with HTTP_ prefix).
     */
    public const HDR_LICENSE_ID = 'X-SwapAds-License-Id';
    public const HDR_TIMESTAMP  = 'X-SwapAds-Timestamp';
    public const HDR_SIGNATURE  = 'X-SwapAds-Signature';
    public const HDR_SOURCE     = 'X-SwapAds-Source';

    /**
     * Register the route.
     */
    public static function register(): void
    {
        add_action('rest_api_init', [self::class, 'registerRoute']);
    }

    /**
     * register_rest_route callback.
     */
    public static function registerRoute(): void
    {
        // Phase 1 (F118): accept POST only. DELETE would be a separate route.
        register_rest_route('swapads-client/v1', self::ROUTE_PATH, [
            'methods'             => 'POST',
            'callback'            => [self::class, 'handle'],
            'permission_callback' => '__return_true',
        ]);
    }

    /**
     * Handle the incoming POST.
     *
     * @param WP_REST_Request $request
     *
     * @return WP_REST_Response
     */
    public static function handle(WP_REST_Request $request): WP_REST_Response
    {
        $validation = self::validateHmac($request);
        if ($validation !== true) {
            return new WP_REST_Response(['error' => $validation], 401);
        }

        $body = (array) $request->get_json_params();
        $status = (string) ($body['status'] ?? '');
        $licenseKeyBody = (string) ($body['license_key'] ?? '');
        if (!in_array($status, ['active', 'paused', 'deleted'], true)) {
            return new WP_REST_Response(['error' => 'invalid_status'], 400);
        }
        // TODO-CLIENT-API-004 (2026-08-01): require a syntactically-valid
        // license_key in the body. validateHmac() now binds it into the
        // HMAC payload, so this MUST match what was signed.
        if (!preg_match('/^[A-Za-z0-9_-]{8,128}$/', $licenseKeyBody)) {
            return new WP_REST_Response(['error' => 'invalid_license_key'], 400);
        }
        $localKey = (string) LicenseManager::key();
        if ($localKey === '') {
            return new WP_REST_Response(['error' => 'local_license_missing'], 412);
        }
        if (!hash_equals($localKey, $licenseKeyBody)) {
            return new WP_REST_Response(['error' => 'license_key_mismatch'], 403);
        }

        // Match the LicenseManager option keys.
        switch ($status) {
            case 'paused':
                update_option(LicenseManager::OPTION_STATUS, 'paused');
                return new WP_REST_Response(['ok' => true, 'action' => 'paused'], 200);

            case 'active':
                update_option(LicenseManager::OPTION_STATUS, 'active');
                return new WP_REST_Response(['ok' => true, 'action' => 'activated'], 200);

            case 'deleted':
                // Clear all local credentials so the operator can re-activate later.
                delete_option(LicenseManager::OPTION_KEY);
                delete_option(LicenseManager::OPTION_SECRET);
                delete_option(LicenseManager::OPTION_STATUS);
                // Reset auto-inject + audience cache so a fresh activation is clean.
                delete_option('swapads_client_audience_cache');
                delete_option('swapads_client_pending_audience_declaration');
                // TODO-CLIENT-API-004 (2026-08-01): ALSO drop:
                //  - the server HMAC secret (otherwise it lingers and could be
                //    reused if the operator re-activates later)
                //  - the pending statistics queue (otherwise orphans accumulate
                //    in wp_options under the previous license's identity)
                EncryptedOption::delete(FreemiusAutoActivator::OPTION_SERVER_SECRET);
                delete_option(StatisticsQueue::OPTION_KEY);
                return new WP_REST_Response(['ok' => true, 'action' => 'deleted_local_credentials_cleared'], 200);
        }
    }

    /**
     * Validate the HMAC signature on the incoming request.
     *
     * @param WP_REST_Request $request
     *
     * @return bool|string true on success, error code string on failure.
     */
    private static function validateHmac(WP_REST_Request $request)
    {
        $licenseId = (int) $request->get_header(self::HDR_LICENSE_ID);
        $timestamp = (string) $request->get_header(self::HDR_TIMESTAMP);
        $signature = (string) $request->get_header(self::HDR_SIGNATURE);
        $source    = (string) $request->get_header(self::HDR_SOURCE);

        if ($licenseId <= 0 || $timestamp === '' || $signature === '') {
            return 'missing_headers';
        }
        if ($source !== 'swapads-server') {
            return 'invalid_source';
        }
        // Window check.
        $ts = (int) $timestamp;
        if (abs(time() - $ts) > self::HMAC_WINDOW_SECONDS) {
            return 'timestamp_out_of_window';
        }
        // HMAC secret is the license_key (locally stored).
        $licenseKey = LicenseManager::key();
        if (!is_string($licenseKey) || $licenseKey === '') {
            return 'license_not_activated';
        }
        // Body status (we need the status to bind into the HMAC payload).
        $body = (array) $request->get_json_params();
        $status = (string) ($body['status'] ?? '');
        // TODO-CLIENT-API-004 (2026-08-01): bind license_key into HMAC payload
        // so a body-modification attack that changes license_key is detected.
        $licenseKeyBody = (string) ($body['license_key'] ?? '');
        $payload = sprintf('license_status:%d:%d:%s:%s', $licenseId, $ts, $status, $licenseKeyBody);
        $expected = hash_hmac('sha256', $payload, $licenseKey);
        if (!hash_equals($expected, $signature)) {
            return 'bad_signature';
        }
        return true;
    }
}