<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Gateway;

use Numinix\SeekmodoWordPress\Config\Options;

/**
 * Cached, per-site browser-token mint.
 *
 * Browser tokens are short-lived JWTs the gateway issues so the
 * storefront's own JS can POST `mcp.seekmodo.com/v1/typeahead`,
 * `/v1/search`, and the rest of the read-only browser-scoped tools
 * directly — without going through WP REST first. That round-trip
 * elimination is what closes the "every keystroke pays a ~1.5 s
 * WordPress bootstrap" problem on heavy WP installs (POCO with
 * WooCommerce + Wordfence, Trek with its theme stack).
 *
 * Lifecycle (per-site, anonymous; we do NOT mint per-shopper):
 *
 *   1. Storefront page render asks for a token via
 *      {@see BrowserToken::current()} from `Frontend/TypeaheadUI`.
 *   2. We check the WP transient cache — if a token is present and
 *      has at least RENEW_BUFFER_S seconds of life left, return it.
 *   3. Otherwise HMAC-POST `/v1/tenants/token` with a 300 s TTL.
 *      Cache the result for `min(ttl - RENEW_BUFFER_S, MAX_CACHE_S)`
 *      so we always have headroom before the gateway considers the
 *      token stale.
 *
 * Failure mode: every error path (gateway 5xx, breaker open, mint
 * disabled, sodium_compat missing) returns null. Callers fall back
 * to the WP REST hop — slower but always correct, so the typeahead
 * never breaks. The cache stores nothing on failure so we don't
 * pin a 5xx for 5 minutes.
 *
 * Anonymity rationale: token's `sid` claim defaults to a synthesised
 * id minted by the gateway. We deliberately don't bind the token to
 * the WP user (logged-in or otherwise) because:
 *
 *   - typeahead is shopper-grade; a single page load may render the
 *     token to thousands of cached HTML pages (Cloudflare, WP Rocket,
 *     LiteSpeed), so per-user binding would either pollute caches
 *     or force a per-user fragmented cache.
 *   - the tool surface the token unlocks is read-only
 *     (BrowserTokenPolicy::ALLOWED), so a leaked token grants
 *     "ability to query Seekmodo as this tenant for ~5 min" — the
 *     same surface the public storefront search input exposes anyway.
 */
final class BrowserToken
{
    /**
     * Buffer in seconds: refresh the cached token when fewer than
     * this many seconds remain on its expiry. Lets a long-running
     * page-load->typing window (open tab + walk away + come back)
     * still get a fresh token without the SDK having to re-mint.
     */
    private const RENEW_BUFFER_S = 60;

    /**
     * Hard cap on how long we cache regardless of the gateway's
     * issued TTL. Defends against a misconfigured / very-long mint
     * silently keeping a stale token alive across plan changes,
     * tenant-secret rotation, or breaker incidents.
     */
    private const MAX_CACHE_S = 240;

    /**
     * Mint TTL we ask the gateway for (seconds). The gateway floors
     * this at 30 and ceils it at 900; 300 is the gateway default
     * and what the SDK uses elsewhere.
     */
    private const MINT_TTL_S = 300;

    /**
     * Transient key. Tenant-scoped so re-pairing (different tenant_id)
     * doesn't reuse the previous tenant's token. The host is included
     * so a multi-site WP install with two paired sub-sites doesn't
     * cross-pollute.
     */
    private static function transientKey(): string
    {
        return 'seekmodo_wp_browser_token_' . substr(
            sha1(Options::tenantId() . '|' . self::host()),
            0,
            16
        );
    }

    private static function host(): string
    {
        $h = '';
        if (function_exists('wp_parse_url') && function_exists('home_url')) {
            $parts = wp_parse_url((string) home_url());
            $h = isset($parts['host']) ? (string) $parts['host'] : '';
        } elseif (isset($_SERVER['HTTP_HOST'])) {
            $h = (string) $_SERVER['HTTP_HOST'];
        }

        return strtolower($h);
    }

    /**
     * Return a usable browser token + metadata, or null if minting
     * is impossible / failed.
     *
     * @return array{token:string,expires_at:int,issued_at:int,session_id:string,gateway_url:string,tenant:string}|null
     */
    public static function current(): ?array
    {
        if (!Options::isPaired()) {
            return null;
        }

        $cached = function_exists('get_transient') ? get_transient(self::transientKey()) : false;
        $now = time();
        if (
            is_array($cached)
            && isset($cached['token'], $cached['expires_at'])
            && (int) $cached['expires_at'] - $now > self::RENEW_BUFFER_S
        ) {
            return $cached;
        }

        $minted = self::mintFresh();
        if ($minted === null) {
            return null;
        }

        $cacheFor = max(
            30,
            min(
                self::MAX_CACHE_S,
                ((int) $minted['expires_at']) - $now - self::RENEW_BUFFER_S
            )
        );
        if (function_exists('set_transient')) {
            set_transient(self::transientKey(), $minted, $cacheFor);
        }

        return $minted;
    }

    /**
     * Mint a fresh token by calling /v1/tenants/token. Returns null
     * on every failure mode; the caller falls back to the WP REST
     * hop which is always correct (just slower).
     *
     * @return array{token:string,expires_at:int,issued_at:int,session_id:string,gateway_url:string,tenant:string}|null
     */
    private static function mintFresh(): ?array
    {
        $resp = Client::tenantToken(['ttl_seconds' => self::MINT_TTL_S]);
        if (!is_array($resp) || !isset($resp['token'], $resp['expires_at'])) {
            return null;
        }
        $token = (string) $resp['token'];
        if ($token === '') {
            return null;
        }

        return [
            'token'       => $token,
            'expires_at'  => (int) $resp['expires_at'],
            'issued_at'   => isset($resp['issued_at']) ? (int) $resp['issued_at'] : time(),
            'session_id'  => isset($resp['session_id']) ? (string) $resp['session_id'] : '',
            'gateway_url' => Options::gatewayUrl(),
            'tenant'      => Options::tenantId(),
        ];
    }

    /**
     * Operator-facing: clear the cached token immediately. Called
     * from `tenant.secret.rotate` and the manual "Reset pairing"
     * settings action so a freshly-rotated tenant secret doesn't
     * keep emitting tokens signed with the prior key.
     */
    public static function invalidate(): void
    {
        if (function_exists('delete_transient')) {
            delete_transient(self::transientKey());
        }
    }
}
