<?php

namespace App\Services;

use App\Models\AuditEvent;
use App\Models\BannedIp;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;

/**
 * Application-level brute-force IP ban for the CONTROL PLANE's own login. Independent of
 * the remote-fleet Fail2banService: this blocks at the HTTP layer (BlockBannedIp middleware),
 * not via SSH firewall rules. Failures are counted in cache; bans are persisted in banned_ips.
 */
class BruteforceGuard
{
    /** Always exempt — hard, non-configurable (prevents loopback/health self-lockout). */
    private const LOOPBACK = ['127.0.0.0/8', '::1', '::ffff:127.0.0.0/104'];

    /** Default operator whitelist: private ranges (covers LAN/VPN + the Docker/proxy net). */
    private const DEFAULT_WHITELIST = "10.0.0.0/8\n172.16.0.0/12\n192.168.0.0/16";

    /** Ban-check cache TTL (seconds). Stale window on natural expiry; keep <= bantime()/2. */
    private const CHECK_TTL = 30;

    public function enabled(): bool
    {
        return Setting::get('bruteforce_enabled', '1') === '1';
    }

    public function maxretry(): int
    {
        return max(1, (int) Setting::get('bruteforce_maxretry', '10'));
    }

    /** findtime in MINUTES. */
    public function findtime(): int
    {
        return max(1, (int) Setting::get('bruteforce_findtime', '10'));
    }

    /** bantime in MINUTES. */
    public function bantime(): int
    {
        return max(1, (int) Setting::get('bruteforce_bantime', '60'));
    }

    /** @return string[] */
    public function whitelist(): array
    {
        $raw = (string) Setting::get('bruteforce_whitelist', self::DEFAULT_WHITELIST);

        return array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $raw) ?: [])));
    }

    public function isExempt(string $ip): bool
    {
        if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
            return true; // unknown/malformed IP → never count or ban
        }
        $ip = $this->canonical($ip); // fold IPv4-mapped IPv6 so exemption matches the canonical form

        foreach (array_merge(self::LOOPBACK, $this->whitelist()) as $cidr) {
            if ($this->matchesCidr($ip, $cidr)) {
                return true;
            }
        }

        return false;
    }

    public function record(?string $ip, string $reason): void
    {
        if (! $this->enabled() || ! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) {
            return;
        }

        $canonical = $this->canonical($ip);
        $key = 'bruteforce:'.md5($canonical);
        $hits = RateLimiter::hit($key, $this->findtime() * 60); // minutes → seconds; hit() returns the count

        if ($hits < $this->maxretry()) {
            return;
        }

        // Ban + audit at most once per IP per window even if two requests cross the threshold
        // concurrently (the upsert is idempotent; this also de-dupes the audit row).
        if (! Cache::add('bruteforce:banning:'.$canonical, true, 60)) {
            return;
        }

        // Atomic upsert keyed on the unique ip index. Eloquent upsert needs explicit timestamps
        // for the bulk path; a single $now keeps created_at/updated_at identical.
        $now = now();
        BannedIp::upsert(
            [[
                'ip' => $canonical,
                'banned_until' => $now->copy()->addMinutes($this->bantime()),
                'reason' => $reason,
                'attempts' => $hits,
                'created_at' => $now,
                'updated_at' => $now,
            ]],
            ['ip'],
            ['banned_until', 'reason', 'attempts', 'updated_at'],
        );
        RateLimiter::clear($key);
        Cache::forget('bruteforce:banned:'.$canonical);

        AuditEvent::create([
            'user_id' => null,
            'actor' => 'system',
            'action' => 'auth.ip_banned',
            'target' => $canonical,
            'ip' => $canonical,
            'meta' => ['reason' => $reason, 'attempts' => $hits],
        ]);
    }

    /**
     * Ban an IP on the FIRST hit — no maxretry threshold, no RateLimiter window. Used by the
     * honeypot deception layer: a request to a decoy path (or a leaked honeytoken) is proof of
     * hostile intent, so there is nothing to count. Mirrors the ban write in record() minus the
     * throttling. Deliberately DOES NOT check enabled(): the honeypot flag already gates whether the
     * trap/detector run, so a honeypot hit must ban regardless of the brute-force login toggle (a
     * disabled brute-force protection must not silently disable honeypot bans). Returns true when a
     * ban was written, false when it no-op'd (invalid / exempt IP) — the caller stays a cheap no-op
     * for legitimate traffic. Reuses the same private helpers as record() (canonical/isExempt/bantime/maxretry).
     */
    public function banNow(?string $ip, string $reason): bool
    {
        if (! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) {
            return false;
        }

        $canonical = $this->canonical($ip);

        $now = now();
        BannedIp::upsert(
            [[
                'ip' => $canonical,
                'banned_until' => $now->copy()->addMinutes($this->bantime()),
                'reason' => $reason,
                'attempts' => $this->maxretry(),
                'created_at' => $now,
                'updated_at' => $now,
            ]],
            ['ip'],
            ['banned_until', 'reason', 'attempts', 'updated_at'],
        );
        Cache::forget('bruteforce:banned:'.$canonical);

        // Dedup the ban-audit: the honeypot calls banNow on EVERY decoy POST, so without this a prober
        // looping the fake login would write one auth.ip_banned row per request. Emit the audit at most
        // once per IP+reason per 60s (Cache::add is atomic); the upsert above stays idempotent. The key
        // includes $reason so a DISTINCT-reason ban of the same IP (e.g. a honeytoken trip after a
        // honeypot ban) is still audited. A short 60s TTL (matching record()'s ban dedup) collapses the
        // rapid scanner flood WITHOUT masking a legitimate later re-ban — e.g. after an operator unban,
        // which is minutes of human time later, well past this window (so it is not tied to unban()).
        $auditKey = 'bruteforce:banaudit:'.$canonical.':'.$reason;
        if (Cache::add($auditKey, 1, 60)) {
            try {
                AuditEvent::create([
                    'user_id' => null,
                    'actor' => 'system',
                    'action' => 'auth.ip_banned',
                    'target' => $canonical,
                    'ip' => $canonical,
                    'meta' => ['reason' => $reason],
                ]);
            } catch (\Throwable $e) {
                // The Cache::add above atomically CLAIMED the dedup slot before the write. If the
                // write throws (a transient DB blip), release the slot so the 60s window is not
                // burned with no audit row — the next attempt re-claims and re-audits this ban.
                Cache::forget($auditKey);
                throw $e;
            }
        }

        return true;
    }

    /**
     * Whether $ip has an active ban. Does NOT check enabled() — the BlockBannedIp middleware
     * gates on enabled() before calling this, so disabling the feature stops enforcement while
     * existing rows persist and resume if it is re-enabled (spec §7).
     */
    public function isBanned(string $ip): bool
    {
        if ($this->isExempt($ip)) {
            return false; // exempt short-circuits — whitelisting unblocks immediately
        }

        $canonical = $this->canonical($ip);

        return Cache::remember('bruteforce:banned:'.$canonical, self::CHECK_TTL, fn () => BannedIp::query()
            ->where('ip', $canonical)
            ->where('banned_until', '>', now())
            ->exists());
    }

    public function unban(string $ip): void
    {
        $canonical = $this->canonical($ip);
        BannedIp::query()->where('ip', $canonical)->delete();
        Cache::forget('bruteforce:banned:'.$canonical);
    }

    /** Delete every ban (active or expired) and bust each cache key. Returns the count removed. */
    public function unbanAll(): int
    {
        $ips = BannedIp::query()->pluck('ip');
        foreach ($ips as $ip) {
            Cache::forget('bruteforce:banned:'.$this->canonical($ip));
        }
        BannedIp::query()->delete();

        return $ips->count();
    }

    /** inet_pton-based CIDR / single-IP match (IPv4, IPv6, IPv4-mapped). */
    public function matchesCidr(string $ip, string $cidr): bool
    {
        if (! str_contains($cidr, '/')) {
            $a = @inet_pton($ip);
            $b = @inet_pton($cidr);

            return $a !== false && $b !== false && $a === $b;
        }

        [$subnet, $maskStr] = explode('/', $cidr, 2);
        $pip = @inet_pton($ip);
        $psub = @inet_pton($subnet);
        if ($pip === false || $psub === false || strlen($pip) !== strlen($psub) || ! ctype_digit($maskStr)) {
            return false; // v4-vs-v6 mismatch or junk mask
        }

        $mask = (int) $maskStr;
        if ($mask > strlen($pip) * 8) {
            return false; // mask wider than the address family (e.g. IPv4 /33)
        }
        $bytes = intdiv($mask, 8);
        $rem = $mask % 8;
        if ($bytes > 0 && substr($pip, 0, $bytes) !== substr($psub, 0, $bytes)) {
            return false;
        }
        if ($rem === 0) {
            return true;
        }
        $b = 0xFF & (0xFF << (8 - $rem));

        return (ord($pip[$bytes]) & $b) === (ord($psub[$bytes]) & $b);
    }

    private function canonical(string $ip): string
    {
        $packed = @inet_pton($ip);
        if ($packed === false) {
            return $ip;
        }
        // Fold an IPv4-mapped IPv6 (::ffff:a.b.c.d) down to plain IPv4 so the same client can't
        // dodge a ban by switching between the two forms (one ban row + one cache key per IP).
        if (strlen($packed) === 16 && str_starts_with($packed, "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff")) {
            $packed = substr($packed, 12);
        }

        return (string) inet_ntop($packed);
    }
}
