<?php

namespace Licenzo\Http\Middleware;

if (!defined('ABSPATH')) {
    exit;
}

use Closure;
use Licenzo\Framework\Foundation\Application;
use Licenzo\Framework\Http\Middleware\AbstractMiddleware;
use Licenzo\Framework\Http\Request;
use Licenzo\Framework\Http\Response;
use Licenzo\Support\RateLimitStore;
use Licenzo\Support\RateLimitStoreException;

/**
 * Rate-limit middleware.
 *
 * USAGE:
 *   Route::group(['middleware' => ['throttle:activation']], ...)
 *   Route::group(['middleware' => ['throttle:public_read']], ...)
 *   Route::group(['middleware' => ['throttle:webhook']], ...)
 *
 * The policy name is parsed by the framework's Pipeline (see
 * Pipeline::parsePipeString) and delivered as the third argument to
 * handle(). Omitting the policy name falls back to 'default'.
 *
 * BUCKET KEY DERIVATION (critical):
 *   The bucket key is derived from the MATCHED ROUTE TEMPLATE
 *   (e.g. "licenzo/v1/activation/{license}/{product}/{variation}"),
 *   NOT from the concrete request URI. Including raw path segments
 *   in the key would produce one bucket per license/product/variation
 *   combination, defeating the cap entirely.
 *
 * SCOPES:
 *   - route   : per-identity, per-route-template bucket.
 *   - global  : per-IP, across-all-routes safety net. Applied only for
 *               policies listed in config app.rate_limit_public_policies.
 *
 * IDENTITY (resolved per request, route-aware):
 *   1. Authenticated WP user         -> "user:<id>"
 *   2. URL has {license} placeholder -> "lic:<sha256(license + salt) :16>"
 *   3. URL has {token} placeholder   -> "qr:<hash>" or "dl:<hash>"
 *   4. Webhook route                 -> "wh:<provider>"
 *   5. No relational identifier      -> "ip:<safe_ip>" (trusted-proxy-
 *                                       aware ClientIpResolver)
 *
 *   Steps 2-4 use stable application-layer identifiers (license keys,
 *   tokens, gateway names) instead of network-layer IPs so legitimate
 *   operators behind shared NAT do not collide on the same bucket and
 *   so spoofed source-IP headers cannot bypass per-resource caps. The
 *   salted hash prevents a leaked bucket key from being reversed back
 *   to the underlying license / token offline.
 *
 * FAILURE POLICY (asymmetric):
 *   - Store failure on a public-rate-limited route -> return 429
 *     (fail closed). Under attack, refusing is safer than allowing.
 *   - Store failure on a non-public/internal policy -> pass through
 *     (fail open). Locking admins out during a cache outage is worse
 *     than the abuse risk.
 *
 * STORE-FAILURE SIGNAL:
 *   When the counter store throws, we fire the
 *   'licenzo/rate_limit/store_failure' action hook with a structured
 *   payload (policy, fail_mode, salted identity hash, route template).
 *   The payload contains NO raw IPs, NO user IDs, NO exception messages
 *   (which could leak store internals). Customers can subscribe to
 *   route this into Sentry/Datadog/etc.
 *
 * @see src/Support/RateLimitStore.php
 * @see src/Support/ClientIpResolver.php
 */
class RateLimit extends AbstractMiddleware
{
    protected RateLimitStore $store;

    public function __construct(Application $app)
    {
        parent::__construct($app);
        $this->store = new RateLimitStore();
    }

    /**
     * Handle an incoming request.
     *
     * @param string $policyName Third argument is supplied by the
     *                           Pipeline's `throttle:<policy>` syntax.
     */
    public function handle(Request $request, Closure $next, string $policyName = 'default')
    {
        $policy = $this->resolvePolicy($policyName);
        $policyName = $policy['__name'];

        $identity      = $this->resolveIdentity($request);
        $routeTemplate = $this->resolveRouteTemplate();

        $routeBucketKey = $this->buildBucketKey($identity, $routeTemplate, 'route');

        try {
            $routeCount = $this->store->hit($routeBucketKey, $policy['window']);
        } catch (RateLimitStoreException $e) {
            return $this->handleStoreFailure($identity, $policyName, $request, $next, $policy);
        }

        if ($routeCount > $policy['requests']) {
            return $this->buildRateLimitResponse($routeBucketKey, $policy);
        }

        if ($this->isPublicPolicy($policyName)) {
            $globalLimit = $this->resolveGlobalLimit();
            $safeIp      = $request->ip();
            $globalKey   = $this->buildBucketKey("ip:{$safeIp}", '*', 'global');

            try {
                $globalCount = $this->store->hit($globalKey, $globalLimit['window']);
            } catch (RateLimitStoreException $e) {
                return $this->handleStoreFailure($identity, $policyName, $request, $next, $policy);
            }

            if ($globalCount > $globalLimit['requests']) {
                return $this->buildRateLimitResponse($globalKey, $globalLimit);
            }
        }

        $response = $next($request);

        if ($response instanceof Response) {
            $this->addRateLimitHeaders($response, $routeBucketKey, $policy);
        }

        return $response;
    }

    /**
     * Resolve the named policy config.
     *
     * Unknown names silently fall back to 'default' with a one-time
     * warning. This is the deliberate "loud failure" posture for
     * typos in route declarations.
     *
     * Returned array has a '__name' key reflecting the ACTUAL policy
     * that will be applied (after fallback resolution), which drives
     * downstream public-policy checks.
     *
     * @return array{requests:int, window:int, __name:string}
     */
    protected function resolvePolicy(string $policyName): array
    {
        $policies = $this->app->config('app.rate_limits', []);
        if (!is_array($policies)) {
            $policies = [];
        }

        if (!isset($policies[$policyName])) {
            $this->warnOnce("unknown rate-limit policy '{$policyName}', falling back to 'default'");
            $policyName = 'default';
        }

        $policy = $policies[$policyName] ?? ['requests' => 60, 'window' => 3600];

        return [
            'requests' => (int) ($policy['requests'] ?? 60),
            'window'   => (int) ($policy['window']   ?? 3600),
            '__name'   => $policyName,
        ];
    }

    /**
     * @return array{requests:int, window:int}
     */
    protected function resolveGlobalLimit(): array
    {
        $limit = $this->app->config('app.rate_limit_global_per_ip', []);
        if (!is_array($limit)) {
            $limit = [];
        }
        return [
            'requests' => (int) ($limit['requests'] ?? 1000),
            'window'   => (int) ($limit['window']   ?? 3600),
        ];
    }

    protected function isPublicPolicy(string $policyName): bool
    {
        $list = $this->app->config('app.rate_limit_public_policies', []);
        return is_array($list) && in_array($policyName, $list, true);
    }

    /**
     * Resolve the bucket identity for the current request.
     *
     * Resolution order (first match wins):
     *   1. Authenticated WP user        -> "user:<id>"
     *   2. Route carries a relational id (license / token / webhook
     *      provider) extractable from the matched route template
     *      -> "lic:<hash>" / "qr:<hash>" / "dl:<hash>" / "wh:<provider>"
     *   3. Anonymous, no relational id  -> "ip:<safe_resolved_ip>"
     *
     * The fallback to IP preserves the v1.0.0 behavior for header-
     * authenticated routes (`/activate`, `/validate`, `/deactivate`,
     * `/updates/check`, `/updates/info`) that have no URL placeholder
     * to bind on. Routes that DO carry a placeholder shift to relational
     * bucketing, which is both spoof-resistant and shared-NAT safe.
     */
    protected function resolveIdentity(Request $request): string
    {
        if (function_exists('is_user_logged_in') && is_user_logged_in()) {
            $userId = (int) get_current_user_id();
            if ($userId > 0) {
                return "user:{$userId}";
            }
        }

        $relational = $this->extractRelationalIdentity($request);
        if ($relational !== null) {
            return $relational;
        }

        return 'ip:' . $request->ip();
    }

    /**
     * Derive an application-layer identity from the matched route
     * template + request parameters.
     *
     * Returns null when no relational identifier is available; the
     * caller then falls back to IP-based identity.
     *
     * Bucket prefixes:
     *   lic: license key (legacy URL-path activation/check/deactivation
     *        and legacy URL-path update check / info)
     *   qr:  QR-code verify token (/verify/{token})
     *   dl:  signed download token (/updates/download/{token})
     *   wh:  webhook provider name (/webhooks/<provider>)
     *
     * Relational identifiers are stable across proxy hops that change
     * a request's source IP (so legitimate operators behind shared NAT
     * do not collide on each other's buckets), and they are not
     * attacker-controllable in the spoofing sense (a forged license key
     * fails the controller's auth check long before its bucket fills
     * meaningfully, and a forged webhook provider name only changes
     * which 600/min bucket the attacker drains for themselves).
     */
    protected function extractRelationalIdentity(Request $request): ?string
    {
        $template = $this->resolveRouteTemplate();

        if (preg_match('#/webhooks/([a-z0-9_-]+)$#i', $template, $matches)) {
            return 'wh:' . strtolower($matches[1]);
        }

        if (strpos($template, '{license}') !== false) {
            $license = $this->paramString($request, 'license');
            if ($license !== '') {
                return 'lic:' . $this->hashRelationalKey($license);
            }
        }

        if (strpos($template, '{token}') !== false) {
            $token = $this->paramString($request, 'token');
            if ($token !== '') {
                $prefix = (strpos($template, '/verify/') !== false) ? 'qr' : 'dl';
                return $prefix . ':' . $this->hashRelationalKey($token);
            }
        }

        return null;
    }

    /**
     * Coerce a request parameter to a trimmed string.
     *
     * Non-scalar inputs (arrays, objects from a malformed REST request)
     * are treated as empty so they fall through to IP-based identity
     * rather than producing a key the store cannot safely accept.
     */
    protected function paramString(Request $request, string $key): string
    {
        $value = $request->get($key, '');
        if (!is_scalar($value)) {
            return '';
        }
        return trim((string) $value);
    }

    /**
     * Opaque salted hash for license keys / tokens used as bucket
     * identity.
     *
     * Produces a 16-char hex prefix that is stable within one site
     * (so the same identifier maps to the same bucket across requests)
     * and meaningless across sites (different AUTH_SALT). The salt
     * prevents trivial offline reversal of a leaked bucket key into
     * the underlying license / token. The 16-char prefix is sufficient:
     * collision risk is cosmetic — two distinct identifiers sharing a
     * bucket at most slightly throttles both, never bypasses.
     */
    protected function hashRelationalKey(string $value): string
    {
        $salt = '';
        if (defined('AUTH_SALT') && is_string(AUTH_SALT)) {
            $salt = AUTH_SALT;
        } elseif (function_exists('wp_salt')) {
            $salt = (string) wp_salt();
        }
        return substr(hash('sha256', $value . '|' . $salt), 0, 16);
    }

    /**
     * Resolve the matched route template from the request context.
     *
     * Route::getUri() returns "<prefix>/<uri>" which for public routes
     * is e.g. "licenzo/v1/activation/{license}/{product}/{variation}".
     * The placeholders are intentionally preserved — they are what
     * collapses per-license buckets into a single per-route bucket.
     *
     * Fallback path sanitizes the raw URI by replacing long segments
     * with '{id}'. This should not be hit during normal operation but
     * prevents per-segment bucket fragmentation from reappearing via
     * any code path that forgets to set the matched route on the
     * context.
     */
    protected function resolveRouteTemplate(): string
    {
        try {
            $context = $this->app->make('request.context');
            if ($context) {
                $route = $context->getRoute();
                if ($route && method_exists($route, 'getUri')) {
                    $template = (string) $route->getUri();
                    if ($template !== '') {
                        return $template;
                    }
                }
            }
        } catch (\Throwable $e) {
            // Fall through to sanitized fallback
        }

        $raw  = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '/';
        $path = wp_parse_url($raw, PHP_URL_PATH);
        if (!is_string($path) || $path === '') {
            $path = $raw;
        }
        // Replace long opaque segments (UUIDs, license keys, slugs) with a placeholder.
        $path = preg_replace('#/[A-Za-z0-9_-]{8,}#', '/{id}', $path);
        return 'fallback:' . $path;
    }

    /**
     * Build a bucket key.
     *
     * sha1 is used purely to bound key length for transient/cache
     * backends. Collision resistance is not a security property here:
     * even a perfect collision between two users only mildly throttles
     * both; it does not bypass.
     */
    protected function buildBucketKey(string $identity, string $routeTemplate, string $scope): string
    {
        return 'rl_' . sha1($identity . '|' . $routeTemplate . '|' . $scope);
    }

    /**
     * Build a 429 response.
     *
     * Retry-After currently uses the policy window length. A future
     * refinement may use the remaining TTL of the specific bucket
     * (RateLimitStore::ttl()) for a tighter signal, but the policy
     * window is a safe upper bound.
     *
     * @param array{requests:int, window:int} $policy
     */
    protected function buildRateLimitResponse(string $bucketKey, array $policy): Response
    {
        $retryAfter = (int) $policy['window'];

        return new Response([
            'success'     => false,
            'message'     => 'Rate limit exceeded',
            'error'       => 'Too many requests. Please try again later.',
            'retry_after' => $retryAfter,
            'limit'       => (int) $policy['requests'],
            'window'      => (int) $policy['window'],
        ], 429, [
            'Retry-After'        => (string) $retryAfter,
            'X-RateLimit-Limit'  => (string) $policy['requests'],
            'X-RateLimit-Window' => (string) $policy['window'],
        ]);
    }

    /**
     * @param array{requests:int, window:int} $policy
     */
    protected function addRateLimitHeaders(Response $response, string $bucketKey, array $policy): void
    {
        $current   = $this->store->peek($bucketKey);
        $remaining = max(0, (int) $policy['requests'] - $current);

        $response->header('X-RateLimit-Limit', (string) $policy['requests']);
        $response->header('X-RateLimit-Remaining', (string) $remaining);
        $response->header('X-RateLimit-Window', (string) $policy['window']);

        if ($remaining === 0) {
            $response->header('Retry-After', (string) $policy['window']);
        }
    }

    /**
     * Asymmetric store-failure handler.
     *
     * Fires the 'licenzo/rate_limit/store_failure' action hook for
     * external monitoring (Sentry, Datadog, custom log shippers).
     * The payload is intentionally narrow:
     *   - policy        : policy name being applied
     *   - fail_mode     : 'closed' (returned 429) or 'open' (passed through)
     *   - identity_hash : 12-char salted hash of the identity (no PII)
     *   - route         : matched route template, or '' if unresolved
     *
     * No raw IPs, user IDs, or exception messages are exposed. This
     * keeps the signal useful for ops while preventing log aggregators
     * from fingerprinting cache backends or correlating to real users.
     *
     * @param array{requests:int, window:int} $policy
     */
    protected function handleStoreFailure(string $identity, string $policyName, Request $request, Closure $next, array $policy)
    {
        $isPublic = $this->isPublicPolicy($policyName);

        do_action('licenzo/rate_limit/store_failure', [
            'policy'        => $policyName,
            'fail_mode'     => $isPublic ? 'closed' : 'open',
            'identity_hash' => $this->hashIdentity($identity),
            'route'         => $this->resolveRouteTemplate(),
        ]);

        if ($isPublic) {
            return $this->buildRateLimitResponse('store_failure', $policy);
        }

        return $next($request);
    }

    /**
     * Short salted hash of the identity string for log safety.
     *
     * Uses AUTH_SALT if defined (always present in a real WP install),
     * else wp_salt() — both are site-unique, so the hash is stable
     * within a site and meaningless across sites.
     */
    protected function hashIdentity(string $identity): string
    {
        $salt = '';
        if (defined('AUTH_SALT') && is_string(AUTH_SALT)) {
            $salt = AUTH_SALT;
        } elseif (function_exists('wp_salt')) {
            $salt = (string) wp_salt();
        }
        return substr(hash('sha256', $identity . '|' . $salt), 0, 12);
    }

    /**
     * Per-request de-duplicated warning log.
     *
     * Prevents log spam when a misconfigured route fires many
     * requests — the warning lands once per process lifetime.
     */
    protected function warnOnce(string $message): void
    {
        static $warned = [];
        if (isset($warned[$message])) {
            return;
        }
        $warned[$message] = true;
        $this->logError($message);
    }
}
