<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Gateway;

use Numinix\SeekmodoWordPress\Config\DomainLock;
use Numinix\SeekmodoWordPress\Config\Options;
use Numinix\SeekmodoWordPress\Mode\AutoPromoter;

/**
 * Thin HTTP client for `mcp.seekmodo.com`. Every public method either
 * returns the parsed gateway response on success, or `null` to signal
 * "caller should fall back to native code path".
 *
 * `null` is returned on EVERY failure mode below — never an exception:
 *
 *   - Connector disabled (MODE=off / no credentials / domain locked out).
 *   - Circuit breaker open.
 *   - Network error (timeout / DNS / TLS).
 *   - 5xx response (also records breaker failure).
 *   - 4xx response (does NOT record breaker failure; that's a client bug).
 *   - JSON decode failure.
 *
 * Returning null instead of throwing keeps the integration contract
 * simple: callers wire the helpers without any try/catch boilerplate
 * and the connector's "always-on safety net" guarantee is enforced at
 * the only place that matters — this class.
 */
final class Client
{
    private const DEFAULT_TIMEOUT_MS = 250;
    /** Events batches do last-touch attribution on the gateway; 250ms is too tight. */
    private const EVENTS_TIMEOUT_MS = 8000;
    private const MAX_RETRIES        = 2;
    private const USER_AGENT         = 'seekmodo-wordpress-connector/';

    /**
     * Ranked-search endpoint.
     *
     * @param array<string,mixed> $params Search params (q, page, page_size, filters, lang, vertical).
     * @return array<string,mixed>|null
     */
    public static function search(array $params): ?array
    {
        $params = SearchResponse::prepareParams($params);
        $params = apply_filters('seekmodo_search_params', $params);

        return SearchResponse::normalize(self::post('/v1/search', $params));
    }

    /**
     * Typeahead / autocomplete.
     *
     * @return array<int,array<string,mixed>>|null
     */
    public static function typeahead(string $q, int $max = 8): ?array
    {
        $resp = self::post('/v1/typeahead', [
            'q'       => $q,
            'max'     => max(1, min(20, $max)),
            'vertical' => 'content',
        ]);
        if (!is_array($resp)) {
            return null;
        }

        $rows = $resp['rows'] ?? $resp;

        return is_array($rows) ? $rows : null;
    }

    /**
     * Mint a short-lived browser token (HS256 JWT) the storefront
     * JS uses to POST mcp.seekmodo.com directly, skipping the WP
     * REST hop on hot paths like typeahead. Server-side only —
     * never reachable from a non-paired site (Options::isPaired()
     * gate inside post()). Cached by Gateway/BrowserToken.
     *
     * @param array{ttl_seconds?:int,sid?:string} $body
     * @return array<string,mixed>|null
     */
    public static function tenantToken(array $body): ?array
    {
        return self::post('/v1/tenants/token', $body);
    }

    /**
     * Bulk indexer write. 500-doc batches enforced at the caller.
     *
     * @param array<int,array<string,mixed>> $docs
     * @return array{accepted:int,rejected:int}|null
     */
    public static function bulkUpsert(array $docs, string $collection): ?array
    {
        if (empty($docs)) {
            return ['accepted' => 0, 'rejected' => 0];
        }
        $resp = self::post('/v1/index', [
            'collection' => $collection,
            'docs'       => $docs,
        ]);
        if (!is_array($resp)) {
            return null;
        }

        return [
            'accepted' => (int) ($resp['accepted'] ?? 0),
            'rejected' => (int) ($resp['rejected'] ?? 0),
        ];
    }

    /**
     * Pull the tenant.snapshot (mode, auto_promote, storefront_host,
     * breaker_thresholds). Called from {@see ConfigPullCron}.
     *
     * @return array<string,mixed>|null
     */
    public static function tenantSnapshot(): ?array
    {
        return self::post('/v1/tenant/snapshot', ['since' => (int) get_option(Options::OPT_LAST_SNAPSHOT_AT, 0)]);
    }

    /**
     * Bulk-forward events from the local queue to /v1/events.
     * Returns true on a 2xx ack, false otherwise (caller keeps the
     * rows for next flush tick).
     *
     * @param array<int,array<string,mixed>> $events
     */
    public static function sendEvents(array $events): bool
    {
        if (empty($events)) {
            return true;
        }
        $resp = self::post('/v1/events', ['events' => $events], self::EVENTS_TIMEOUT_MS);

        return is_array($resp);
    }

    /**
     * POST /v1/recommend.{algorithm} (or /v1/bundle.suggest).
     *
     * Allowlisted algorithms only. Returns null on every failure mode
     * including HTTP 402 (`recommendations_not_enabled` /
     * `feature_not_in_plan`) so storefront placements fail soft.
     *
     * @param string $algorithm One of related|also_bought|also_viewed|trending|bundle.suggest
     * @param array<string,mixed> $params
     * @return array<string,mixed>|null
     */
    public static function recommend(string $algorithm, array $params): ?array
    {
        static $allowed = [
            'related'        => '/v1/recommend.related',
            'also_bought'    => '/v1/recommend.also_bought',
            'also_viewed'    => '/v1/recommend.also_viewed',
            'trending'       => '/v1/recommend.trending',
            'bundle.suggest' => '/v1/bundle.suggest',
        ];
        if (!isset($allowed[$algorithm])) {
            return null;
        }

        return self::post($allowed[$algorithm], $params);
    }

    /**
     * Promote the tenant's canonical storefront host (the
     * `locked_domain` field on `numinix_mcp_tenant_config`) from
     * the current value to `$new_host`. Backs the v0.3.0
     * "Promote dev to production" self-service flow.
     *
     * The gateway's `tenant.domain.promote` MCP tool atomically
     * swaps the value, emits a `domain_lock.changed` audit event,
     * and the next 5-min `tenant.snapshot` cron pull on every
     * paired connector picks up the new lock without a re-pair —
     * dev sites silently become no-op clones because
     * {@see DomainLock::matches()} returns false against the new
     * canonical.
     *
     * Authenticated through the same HMAC channel as every other
     * call, so only the paired tenant can flip its own canonical.
     *
     * @return array<string,mixed>|null Gateway response on 2xx, null
     *     on any failure (including 400/403 from the gateway, e.g.
     *     "host has never been observed for this tenant" or "this
     *     tenant has explicit promote-via-admin-only opt-in").
     *     Caller is responsible for surfacing the failure to the
     *     operator.
     */
    public static function promoteCanonicalDomain(string $new_host): ?array
    {
        $new_host = strtolower(trim($new_host));
        if ($new_host === '') {
            return null;
        }

        // URL maps to MCP tool `tenant.domain.promote` via the
        // public/index.php REST shim (`/` -> `.` normalization in
        // `ToolCatalog::get`).
        return self::post('/v1/tenant/domain/promote', [
            'new_host'      => $new_host,
            'previous_host' => DomainLock::lockedHost(),
            'platform'      => 'wordpress',
            'connector_version' => SEEKMODO_WP_VERSION,
        ]);
    }

    /**
     * Authenticated POST. Returns the JSON-decoded body on 2xx, null
     * on any other outcome. Records breaker state side-effects.
     *
     * @param array<string,mixed> $body
     * @return array<string,mixed>|null
     */
    private static function post(string $path, array $body, ?int $timeoutMs = null): ?array
    {
        if (CircuitBreaker::isOpen()) {
            self::recordRequest(0, 0, true);

            return null;
        }
        if (!Options::isPaired()) {
            return null;
        }

        $url       = Options::gatewayUrl() . $path;
        $body_json = wp_json_encode($body);
        if (!is_string($body_json)) {
            return null;
        }

        $signature = Hmac::sign($body_json, Options::sharedSecret());
        $headers   = [
            'Content-Type'                  => 'application/json',
            'X-Seekmodo-Tenant'              => Options::tenantId(),
            'X-Seekmodo-Signature'           => $signature,
            'X-Seekmodo-Storefront-Host'     => DomainLock::currentHost(),
            'X-Seekmodo-Connector-Version'   => SEEKMODO_WP_VERSION,
            'X-Seekmodo-Connector-Platform'  => 'wordpress',
            'X-Seekmodo-Timestamp'           => (string) time(),
        ];

        return self::dispatchWithRetries($url, $headers, $body_json, $timeoutMs);
    }

    /**
     * Unauthenticated POST used by the pairing round-trip (no tenant
     * yet, no HMAC). Returns the JSON-decoded body on 2xx, null
     * otherwise. Does NOT touch the breaker.
     *
     * @param array<string,mixed> $body
     * @return array<string,mixed>|null
     */
    private static function postUnauthenticated(string $path, array $body): ?array
    {
        $url       = Options::gatewayUrl() . $path;
        $body_json = wp_json_encode($body);
        if (!is_string($body_json)) {
            return null;
        }
        $resp = wp_remote_post($url, [
            'timeout'     => 5,
            'redirection' => 0,
            'sslverify'   => true,
            'user-agent'  => self::userAgent(),
            'headers'     => [
                'Content-Type'                 => 'application/json',
                'X-Seekmodo-Connector-Version' => SEEKMODO_WP_VERSION,
                'X-Seekmodo-Storefront-Host'   => DomainLock::currentHost(),
            ],
            'body'        => $body_json,
        ]);
        if (is_wp_error($resp)) {
            return null;
        }
        $code = wp_remote_retrieve_response_code($resp);
        if ($code < 200 || $code >= 300) {
            return null;
        }
        $decoded = json_decode((string) wp_remote_retrieve_body($resp), true);

        return is_array($decoded) ? $decoded : null;
    }

    /**
     * Outbound request with jittered retry + timeout-as-ms. Records
     * breaker state on every attempt.
     *
     * @param array<string,string> $headers
     * @return array<string,mixed>|null
     */
    private static function dispatchWithRetries(
        string $url,
        array $headers,
        string $body_json,
        ?int $timeoutMs = null
    ): ?array {
        $default = $timeoutMs !== null ? $timeoutMs : self::DEFAULT_TIMEOUT_MS;
        $timeout_ms = apply_filters('seekmodo_gateway_timeout_ms', $default);
        // Events path may pass up to EVENTS_TIMEOUT_MS; search stays short.
        $cap = $timeoutMs !== null ? max(2000, $timeoutMs) : 2000;
        $timeout_ms = is_int($timeout_ms) ? max(50, min($cap, $timeout_ms)) : $default;
        // wp_remote_post timeout is seconds (float), so we convert.
        $timeout_sec = $timeout_ms / 1000.0;

        $last_response = null;
        $last_code     = 0;
        $started_at    = microtime(true);

        for ($attempt = 0; $attempt <= self::MAX_RETRIES; $attempt++) {
            $resp = wp_remote_post($url, [
                'timeout'     => $timeout_sec,
                'redirection' => 0,
                'sslverify'   => true,
                'user-agent'  => self::userAgent(),
                'headers'     => $headers,
                'body'        => $body_json,
            ]);

            if (is_wp_error($resp)) {
                CircuitBreaker::recordFailure();
                $last_code = 0;
                $last_response = null;
                self::backoff($attempt);
                continue;
            }

            $code = wp_remote_retrieve_response_code($resp);
            if ($code >= 200 && $code < 300) {
                CircuitBreaker::recordSuccess();
                // Self-recovery (audit P-G, 2026-06-10): a 2xx
                // clears any pending signature_mismatch admin
                // notice. Called on every success rather than only
                // when the tracker is tripped because the option
                // write is a no-op when the row count is already
                // zero.
                SignatureMismatchTracker::clear();
                $latency_ms = (int) round((microtime(true) - $started_at) * 1000);
                self::recordRequest($latency_ms, $code, false);
                $decoded = json_decode((string) wp_remote_retrieve_body($resp), true);

                return is_array($decoded) ? $decoded : null;
            }
            $last_code     = $code;
            $last_response = $resp;
            if ($code >= 500) {
                CircuitBreaker::recordFailure();
                self::backoff($attempt);
                continue;
            }

            // Self-recovery (audit P-G, 2026-06-10): a 401 whose
            // body says `reason=signature_mismatch` means the
            // gateway refused the HMAC and the connector +
            // gateway have drifted apart. Record the failure into
            // the sliding window so the admin notice surfaces
            // "Re-pair required" on the next admin page render.
            // Any other 4xx (rate-limit, malformed payload,
            // tenant_paused) is unrelated and falls through.
            if ($code === 401) {
                $body_str = (string) wp_remote_retrieve_body($resp);
                $decoded  = json_decode($body_str, true);
                if (is_array($decoded) && (($decoded['reason'] ?? null) === 'signature_mismatch')) {
                    SignatureMismatchTracker::recordFailure();
                }
            }

            // 4xx — caller bug; don't trip the breaker, don't retry.
            break;
        }

        $latency_ms = (int) round((microtime(true) - $started_at) * 1000);
        self::recordRequest($latency_ms, $last_code, true);

        return null;
    }

    private static function backoff(int $attempt): void
    {
        // Jittered exponential, capped at 80ms. We're inside a 250ms
        // total budget so anything longer just blows the breaker.
        $base = min(80_000, (int) (10_000 * (2 ** $attempt)));
        $jitter = random_int(0, $base);
        if ($jitter > 0) {
            usleep($jitter);
        }
    }

    private static function recordRequest(int $latency_ms, int $http_code, bool $fell_back): void
    {
        AutoPromoter::recordRequest($latency_ms, $http_code, $fell_back);
    }

    private static function userAgent(): string
    {
        return self::USER_AGENT . SEEKMODO_WP_VERSION . ' (' . DomainLock::currentHost() . ')';
    }
}
