<?php

namespace App\Services;

use App\Models\Server;
use App\Support\Os\OsDetector;
use RuntimeException;

/**
 * fail2ban management over SSH (as root): jail status + banned-IP list, unban,
 * manual ban (lockout-guarded), and the Clusev-managed [DEFAULT] tuning + whitelist.
 *
 * Clusev owns EXACTLY ONE file — the zz-clusev.local drop-in — and rewrites the whole
 * [DEFAULT] block on every change, so the tuning (bantime/findtime/maxretry) and the
 * whitelist (ignoreip) can never clobber each other. Loopback is always whitelisted.
 */
class Fail2banService
{
    // TWO 'zz-' drop-ins (sort LAST in jail.d so their [DEFAULT] wins). Tuning and the
    // whitelist live in SEPARATE files and each writes only its OWN keys, so editing the
    // whitelist can never rewrite (and clobber) the ban policy, and vice-versa.
    private const DROPIN_TUNING = '/etc/fail2ban/jail.d/zz-clusev-tuning.local';

    private const DROPIN_IGNOREIP = '/etc/fail2ban/jail.d/zz-clusev-ignoreip.local';

    // The previous single-file drop-in (tuning only). It sorts AFTER the new split files,
    // so it must be removed on migration or its stale [DEFAULT] would override new tuning.
    private const LEGACY_DROPIN = '/etc/fail2ban/jail.d/zz-clusev.local';

    // Always whitelisted so the operator can never lock fail2ban against localhost.
    private const LOOPBACK = ['127.0.0.1/8', '::1'];

    public function __construct(private FleetService $fleet, private OsDetector $detector) {}

    /** NULL when fail2ban is supported on this host, else a German reason. */
    public function support(Server $server): ?string
    {
        return $this->detector->detect($server)->supports('fail2ban');
    }

    /**
     * Jail status + banned IPs + whitelist in as few round-trips as possible.
     *
     * @return array<string, mixed>
     */
    public function status(Server $server): array
    {
        $reason = $this->support($server);
        $base = ['supported' => $reason === null, 'reason' => $reason, 'installed' => false, 'active' => false, 'jails' => [], 'ignoreip' => []];
        if ($reason !== null) {
            return $base;
        }

        $cmd = 'echo CLUSEV_F2B_OK; '
            .'command -v fail2ban-client >/dev/null 2>&1 && echo INST=yes || echo INST=no; '
            .'systemctl is-active --quiet fail2ban && echo ACT=active || echo ACT=inactive; '
            // CLIENT_OK only when `fail2ban-client status` itself SUCCEEDS — so an active
            // service whose client call fails is not misreported as "0 jails".
            .'cs=$(fail2ban-client status 2>/dev/null) && echo CLIENT_OK; '
            .'jails=$(printf "%s" "$cs" | sed -n "s/.*Jail list:[[:space:]]*//p" | tr "," " "); '
            .'for j in $jails; do echo ===CLUSEV:jail:$j===; fail2ban-client status "$j" 2>/dev/null; done';

        $res = $this->fleet->runPrivileged($server, $cmd);
        if (! str_contains($res['output'], 'CLUSEV_F2B_OK')) {
            return $base + ['readError' => true];
        }

        $out = $res['output'];
        $installed = (bool) preg_match('/^INST=yes$/m', $out);
        $active = (bool) preg_match('/^ACT=active$/m', $out);

        // Service active but the client query failed -> a real read error, not "no jails".
        if ($installed && $active && ! preg_match('/^CLIENT_OK$/m', $out)) {
            return $base + ['readError' => true];
        }

        $jails = [];
        foreach ($this->jailSections($out) as $name => $body) {
            $jails[] = [
                'name' => $name,
                'currentlyFailed' => $this->intField($body, 'Currently failed'),
                'totalFailed' => $this->intField($body, 'Total failed'),
                'currentlyBanned' => $this->intField($body, 'Currently banned'),
                'totalBanned' => $this->intField($body, 'Total banned'),
                'bannedIps' => $this->bannedIps($body),
            ];
        }

        // ignoreip is managed separately (our drop-in); read it best-effort.
        $ignoreip = [];
        try {
            $ignoreip = $this->readConfig($server)['ignoreip'];
        } catch (RuntimeException) {
            // leave empty on read failure
        }

        return ['supported' => true, 'reason' => null, 'installed' => $installed, 'active' => $active, 'jails' => $jails, 'ignoreip' => $ignoreip];
    }

    /**
     * Read the effective [DEFAULT] tuning + whitelist (last value wins across files).
     *
     * @return array{bantime: string, maxretry: int, findtime: string, ignoreip: array<int, string>}
     */
    public function readConfig(Server $server): array
    {
        // A leading sentinel proves the read RAN (sudo/SSH ok); a CLUSEV_RESET marker
        // stops a section bleeding across file boundaries. Only [DEFAULT] keys count.
        $cmd = 'echo CLUSEV_READ_OK; for f in /etc/fail2ban/jail.conf /etc/fail2ban/jail.d/*.conf '
            .'/etc/fail2ban/jail.local /etc/fail2ban/jail.d/*.local; do '
            .'[ -f "$f" ] && { echo "[CLUSEV_RESET]"; cat "$f"; }; done 2>/dev/null';

        $res = $this->fleet->runPrivileged($server, $cmd);
        if (! str_contains($res['output'], 'CLUSEV_READ_OK')) {
            throw new RuntimeException(__('backend.fail2ban_config_read_failed'));
        }

        return $this->parseDefaults($res['output']);
    }

    /**
     * Write ONLY the [DEFAULT] tuning keys to the tuning drop-in (never ignoreip), so a
     * tuning change can't touch the whitelist. Values must already be validated/clamped.
     *
     * @return array{ok: bool, output: string}
     */
    public function writeTuning(Server $server, string $bantime, int $maxretry, string $findtime): array
    {
        $bantime = trim(preg_replace('/\s+/', ' ', $bantime) ?? '');
        $findtime = trim(preg_replace('/\s+/', ' ', $findtime) ?? '');

        $content = "# Managed by Clusev — fail2ban tuning. Do not edit by hand.\n"
            ."[DEFAULT]\n"
            ."bantime = {$bantime}\n"
            ."findtime = {$findtime}\n"
            ."maxretry = {$maxretry}\n";

        // Drop the legacy single-file drop-in (tuning only) so it can't override this.
        return $this->writeDropin($server, self::DROPIN_TUNING, $content, [self::LEGACY_DROPIN]);
    }

    /**
     * Write ONLY the [DEFAULT] ignoreip to the whitelist drop-in (never tuning), so a
     * whitelist change can't touch the ban policy. Loopback is always re-seeded.
     *
     * @param  array<int, string>  $ignoreip
     * @return array{ok: bool, output: string}
     */
    public function writeIgnoreip(Server $server, array $ignoreip): array
    {
        $ignoreip = $this->normalizeIgnoreip($ignoreip);

        $content = "# Managed by Clusev — fail2ban whitelist. Do not edit by hand.\n"
            ."[DEFAULT]\n"
            .'ignoreip = '.implode(' ', $ignoreip)."\n";

        return $this->writeDropin($server, self::DROPIN_IGNOREIP, $content);
    }

    /**
     * Atomically write one drop-in (base64-safe), optionally removing stale files first,
     * then reload fail2ban if it is running.
     *
     * @param  array<int, string>  $removeFirst
     */
    private function writeDropin(Server $server, string $path, string $content, array $removeFirst = []): array
    {
        $rm = '';
        foreach ($removeFirst as $stale) {
            $rm .= 'rm -f '.$stale.'; ';
        }

        $b64 = base64_encode($content);
        $cmd = 'mkdir -p /etc/fail2ban/jail.d; '.$rm
            .'printf %s '.$b64.' | base64 -d > '.$path
            .' && if systemctl is-active --quiet fail2ban; then systemctl reload fail2ban || fail2ban-client reload; fi';

        $res = $this->fleet->runPrivileged($server, $cmd, 120);

        return ['ok' => $res['ok'], 'output' => $this->tail($res['output'])];
    }

    /** Unban one IP from a jail. "is not banned" counts as success (idempotent). */
    public function unban(Server $server, string $jail, string $ip): array
    {
        if (! $this->validJail($jail) || ! filter_var($ip, FILTER_VALIDATE_IP)) {
            return ['ok' => false, 'output' => __('backend.invalid_input')];
        }
        if ($reason = $this->support($server)) {
            return ['ok' => false, 'output' => $reason];
        }

        $res = $this->fleet->runPrivileged($server, 'fail2ban-client set '.$jail.' unbanip '.$ip);
        $ok = $res['ok'] || stripos($res['output'], 'not banned') !== false;

        return ['ok' => $ok, 'output' => $this->tail($res['output'])];
    }

    /** Manually ban an IP in a jail. GUARDED against banning Clusev's own connection. */
    public function ban(Server $server, string $jail, string $ip): array
    {
        if (! $this->validJail($jail) || ! filter_var($ip, FILTER_VALIDATE_IP)) {
            return ['ok' => false, 'output' => __('backend.invalid_input')];
        }
        if ($reason = $this->support($server)) {
            return ['ok' => false, 'output' => $reason];
        }

        // LOCKOUT GUARD: never ban loopback (the WHOLE 127.0.0.0/8 range + ::1) or the
        // address Clusev connects FROM. Compare CANONICALLY (inet_pton) so an alternate
        // IPv6 spelling cannot slip past.
        $cp = $this->controlPlaneIp($server);
        $loopback = $this->sameIp($ip, '::1')
            || (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false && str_starts_with($ip, '127.'));
        if ($loopback || ($cp !== null && $this->sameIp($ip, $cp))) {
            return ['ok' => false, 'output' => __('backend.ip_belongs_to_clusev')];
        }

        $res = $this->fleet->runPrivileged($server, 'fail2ban-client set '.$jail.' banip '.$ip);

        return ['ok' => $res['ok'], 'output' => $this->tail($res['output'])];
    }

    /** Add an IP/CIDR to the whitelist (ignoreip), preserving the tuning values. */
    public function addIgnoreIp(Server $server, string $ip): array
    {
        $ip = trim($ip);
        if (! $this->validIpOrCidr($ip)) {
            return ['ok' => false, 'output' => __('backend.invalid_ip_or_cidr')];
        }
        if ($reason = $this->support($server)) {
            return ['ok' => false, 'output' => $reason];
        }

        $cfg = $this->readConfig($server);
        if (in_array($ip, $cfg['ignoreip'], true)) {
            return ['ok' => true, 'noop' => true, 'output' => __('backend.already_whitelisted')];
        }
        $list = $cfg['ignoreip'];
        $list[] = $ip;

        return $this->writeIgnoreip($server, $list);
    }

    /** Remove an IP/CIDR from the whitelist (loopback can never be removed). */
    public function removeIgnoreIp(Server $server, string $ip): array
    {
        $ip = trim($ip);
        if ($reason = $this->support($server)) {
            return ['ok' => false, 'output' => $reason];
        }
        if (in_array($ip, self::LOOPBACK, true)) {
            return ['ok' => false, 'output' => __('backend.loopback_stays_whitelisted')];
        }

        $cfg = $this->readConfig($server);
        if (! in_array($ip, $cfg['ignoreip'], true)) {
            return ['ok' => true, 'noop' => true, 'output' => __('backend.not_on_whitelist')];
        }
        $list = array_values(array_filter($cfg['ignoreip'], fn (string $e): bool => $e !== $ip));

        return $this->writeIgnoreip($server, $list);
    }

    /** Canonical IP equality (normalizes IPv6 spellings via inet_pton). */
    private function sameIp(string $a, string $b): bool
    {
        $pa = @inet_pton($a);
        $pb = @inet_pton($b);

        return $pa !== false && $pb !== false && $pa === $pb;
    }

    /** Clusev's source IP as the server sees it (from $SSH_CONNECTION), or null. */
    private function controlPlaneIp(Server $server): ?string
    {
        $res = $this->fleet->runPlain($server, 'printf %s "$SSH_CONNECTION"');
        $ip = $res['ok'] ? (preg_split('/\s+/', trim($res['output']))[0] ?? '') : '';

        return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : null;
    }

    /**
     * @return array{bantime: string, maxretry: int, findtime: string, ignoreip: array<int, string>}
     */
    private function parseDefaults(string $body): array
    {
        $vals = ['bantime' => '10m', 'maxretry' => 5, 'findtime' => '10m', 'ignoreip' => self::LOOPBACK];
        $inDefault = false;
        $cont = false;          // currently inside an ignoreip continuation
        $ignoreRaw = null;      // raw ignoreip value (across continuation lines), last file wins

        foreach (preg_split('/\R/', $body) ?: [] as $raw) {
            $t = trim($raw);
            if ($t === '' || $t[0] === '#' || $t[0] === ';') {
                $cont = false;

                continue;
            }
            // Section header (incl. the [CLUSEV_RESET] between files) resets state.
            if (preg_match('/^\[([^\]]+)\]/', $t, $m)) {
                $inDefault = strcasecmp(trim($m[1]), 'DEFAULT') === 0;
                $cont = false;

                continue;
            }
            if (! $inDefault) {
                $cont = false;

                continue;
            }
            // INI continuation: an indented line right after ignoreip extends its value.
            if ($cont && preg_match('/^\s/', $raw)) {
                $ignoreRaw .= ' '.$t;

                continue;
            }
            $cont = false;

            if (preg_match('/^bantime\s*=\s*(.+?)\s*$/i', $t, $m)) {
                $vals['bantime'] = $m[1];
            } elseif (preg_match('/^findtime\s*=\s*(.+?)\s*$/i', $t, $m)) {
                $vals['findtime'] = $m[1];
            } elseif (preg_match('/^maxretry\s*=\s*(\d+)/i', $t, $m)) {
                $vals['maxretry'] = (int) $m[1];
            } elseif (preg_match('/^ignoreip\s*=\s*(.*)$/i', $t, $m)) {
                $ignoreRaw = $m[1];   // last assignment (highest-precedence file) wins
                $cont = true;
            }
        }

        if ($ignoreRaw !== null) {
            $vals['ignoreip'] = $this->normalizeIgnoreip(preg_split('/\s+/', trim($ignoreRaw)) ?: []);
        }

        return $vals;
    }

    /**
     * Loopback first, then every existing entry VERBATIM (deduped) — hostnames, CIDRs
     * and other fail2ban-valid tokens are preserved, never dropped (new entries from
     * the UI are validated separately before they reach this list).
     */
    private function normalizeIgnoreip(array $list): array
    {
        $out = self::LOOPBACK;
        foreach ($list as $entry) {
            $entry = trim((string) $entry);
            if ($entry !== '' && ! in_array($entry, $out, true)) {
                $out[] = $entry;
            }
        }

        return $out;
    }

    /** @return array<string, string> jail name => status body */
    private function jailSections(string $out): array
    {
        $sections = [];
        $current = null;
        $buffer = [];
        foreach (preg_split('/\R/', $out) ?: [] as $line) {
            if (str_starts_with($line, '===CLUSEV:jail:')) {
                if ($current !== null) {
                    $sections[$current] = implode("\n", $buffer);
                }
                $current = rtrim(substr($line, strlen('===CLUSEV:jail:')), '=');
                $buffer = [];
            } elseif ($current !== null) {
                $buffer[] = $line;
            }
        }
        if ($current !== null) {
            $sections[$current] = implode("\n", $buffer);
        }

        return $sections;
    }

    private function intField(string $body, string $label): int
    {
        return preg_match('/'.preg_quote($label, '/').':\s*(\d+)/i', $body, $m) ? (int) $m[1] : 0;
    }

    /** @return array<int, string> */
    private function bannedIps(string $body): array
    {
        if (! preg_match('/Banned IP list:\s*(.*)$/im', $body, $m)) {
            return [];
        }

        return array_values(array_filter(
            preg_split('/\s+/', trim($m[1])) ?: [],
            fn (string $ip): bool => (bool) filter_var($ip, FILTER_VALIDATE_IP)
        ));
    }

    private function validJail(string $jail): bool
    {
        // Must START with an alphanumeric/underscore: a leading '-' would be parsed by
        // fail2ban-client as an OPTION (e.g. "-h", "--help") rather than a jail argument,
        // and a leading '.' has no legitimate jail use. Internal '.-_' stay allowed so real
        // names like "nginx-http-auth" pass. (Shell metacharacters are already inert via the
        // base64 transport in runPrivileged; this closes the argument-injection confusion.)
        return (bool) preg_match('/^[A-Za-z0-9_][A-Za-z0-9._-]*$/', $jail);
    }

    private function validIpOrCidr(string $v): bool
    {
        if (filter_var($v, FILTER_VALIDATE_IP)) {
            return true;
        }
        if (! str_contains($v, '/')) {
            return false;
        }
        [$ip, $mask] = explode('/', $v, 2);
        if (! filter_var($ip, FILTER_VALIDATE_IP) || ! ctype_digit($mask)) {
            return false;
        }
        $max = str_contains($ip, ':') ? 128 : 32;

        return (int) $mask >= 0 && (int) $mask <= $max;
    }

    private function tail(string $out, int $max = 300): string
    {
        $out = trim($out);

        return mb_strlen($out) <= $max ? $out : '…'.mb_substr($out, -$max);
    }
}
