<?php

namespace App\Http\Middleware;

use App\Models\AuditEvent;
use App\Services\BruteforceGuard;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

/**
 * Honeytoken tripwire. The honeypot's fake .env seeds per-install canary secret VALUES that no real
 * system secret ever equals (config('clusev.honeypot.canaries')). If an UNAUTHENTICATED attacker who
 * scraped the decoy .env replays one of those values — as a request-BODY credential field, a bearer
 * token, or an Authorization header — it is undeniable proof they took the bait, so we instant-ban + 403.
 *
 * Deliberately CHEAP and FALSE-POSITIVE-FREE: it only trips for anonymous requests (a logged-in user
 * is never a honeytoken replayer, and this stops a reflected decoy link banning a real victim), it
 * scans only a fixed handful of well-known credential inputs from the request BODY (never the query
 * string, never `password`) plus the bearer token / Authorization header — never a recursive walk.
 * A normal request not carrying a canary is a near-free no-op. Appended to the web group AFTER
 * AuthenticateSession so the session/IP are resolved.
 */
class DetectHoneytoken
{
    /** Request BODY keys that plausibly carry a leaked secret. Kept tiny on purpose; NO `password`. */
    private const CANDIDATE_KEYS = ['api_key', 'apikey', 'token', 'secret', 'key', 'access_key'];

    /** Per-IP cap on honeytoken audit rows per rolling hour (bounds an exempt/rotating source). */
    private const AUDIT_CAP_PER_HOUR = 60;

    public function __construct(private BruteforceGuard $guard) {}

    public function handle(Request $request, Closure $next): Response
    {
        if (! config('clusev.honeypot.enabled')) {
            return $next($request);
        }

        // Only trap UNAUTHENTICATED attackers. A logged-in user is never a honeytoken replayer, and
        // this stops a reflected decoy link from banning an authenticated victim.
        if (auth()->check()) {
            return $next($request);
        }

        $canaries = config('clusev.honeypot.canaries', []);
        if ($canaries === []) {
            return $next($request);
        }

        // A small, bounded set of candidate values — request BODY only (form-encoded via post() AND a
        // JSON body via json()), never the query string (which a reflected link could carry to ban a
        // victim), no recursion into nested arrays.
        $candidates = [];
        foreach (self::CANDIDATE_KEYS as $key) {
            foreach ([$request->post($key), $request->json($key)] as $value) {
                if (is_string($value) && $value !== '') {
                    $candidates[] = $value;
                }
            }
        }
        if (($bearer = $request->bearerToken()) !== null && $bearer !== '') {
            $candidates[] = $bearer;
        }
        if (($auth = $request->header('Authorization')) !== null && $auth !== '') {
            $candidates[] = $auth;
        }

        foreach ($canaries as $name => $canaryValue) {
            if (! is_string($canaryValue) || $canaryValue === '') {
                continue;
            }
            foreach ($candidates as $candidate) {
                if (hash_equals($canaryValue, $candidate)) {
                    return $this->trip($request, (string) $name);
                }
            }
        }

        return $next($request);
    }

    private function trip(Request $request, string $canaryName): Response
    {
        $ip = (string) $request->ip();

        // Ban first, then audit — each guarded independently (mirrors HoneypotController::trap). A write
        // failure (DB hiccup, un-run migration) must never turn a honeytoken trip into a 500 that leaks
        // this is a real app, nor let a failing audit skip the ban. No per-IP audit cap is needed here:
        // BlockBannedIp runs BEFORE this middleware, so a banned IP is 403'd upstream and never reaches
        // trip() again — the honeytoken audit cannot be looped.
        try {
            $this->guard->banNow($ip, 'honeytoken');
        } catch (\Throwable $e) {
            report($e);
        }

        try {
            // Bound audit growth per source IP WITHOUT a race (add seeds a 1h TTL once, atomic increment
            // counts + returns the new total). An EXEMPT IP is never banned (banNow no-ops), so
            // BlockBannedIp cannot throttle its repeat trips; and a briefly-stale ban-check cache can let
            // a few through before the ban takes hold. Past the cap we drop the row — the ban + 403 still
            // happen. Mirrors HoneypotController::auditProbe.
            $capKey = 'honeytoken:audit:'.md5($ip);
            Cache::add($capKey, 0, now()->addHour());
            if (Cache::increment($capKey) <= self::AUDIT_CAP_PER_HOUR) {
                AuditEvent::create([
                    'user_id' => null,
                    'actor' => 'system',
                    'action' => 'security.honeytoken_used',
                    'target' => $canaryName,
                    'ip' => $ip,
                    // path() is derived from the (attacker-controlled) URL — scrub to valid UTF-8 so a
                    // percent-decoded invalid byte can't make the array-cast meta column's json_encode throw.
                    'meta' => ['path' => Str::limit(mb_scrub($request->path()), 190)],
                ]);
            }
        } catch (\Throwable $e) {
            report($e);
        }

        return response()->view('errors.blocked', [], 403);
    }
}
