<?php

namespace App\Services;

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

/**
 * Host firewall control over SSH (as root), abstracted across ufw and firewalld
 * (resolved per OS via OsDetector/FirewallTool).
 *
 * Safety model: "guards + confirmation". enable() ALWAYS opens the real sshd Port
 * (detected from sshd_config, default 22) plus 80/443 BEFORE turning the firewall
 * on — so enabling can never sever the operator's own SSH session.
 *
 * Both mutating methods return array{ok: bool, output: string}.
 */
class FirewallService
{
    public function __construct(private FleetService $fleet, private OsDetector $detector) {}

    /**
     * GUARD: allow the real sshd Port + 80/443 first, THEN enable the firewall.
     * Never enable a firewall that would drop the current SSH session. Installs the
     * firewall package first when supported and missing.
     *
     * @return array{ok: bool, output: string}
     */
    public function enable(Server $server): array
    {
        $os = $this->detector->detect($server);
        if ($reason = $os->supports('firewall')) {
            return ['ok' => false, 'output' => $reason];
        }

        // long timeout: may install the firewall package first.
        $script = FirewallTool::for($os)->enableScript($this->sshPort($server));
        $res = $this->fleet->runPrivileged($server, $script, 600);

        return ['ok' => $res['ok'], 'output' => $res['output']];
    }

    /** Turn the firewall off (clean stop, never a panic/drop-all). @return array{ok: bool, output: string} */
    public function disable(Server $server): array
    {
        $os = $this->detector->detect($server);
        $res = $this->fleet->runPrivileged($server, FirewallTool::for($os)->disableScript());

        return ['ok' => $res['ok'], 'output' => $res['output']];
    }

    /**
     * Read the full firewall state in one privileged round-trip: installed/active,
     * default policies (ufw) or zone+target (firewalld), and the rule list. Returns
     * `supported=false` + a German reason on an OS without a supported firewall.
     *
     * @return array<string, mixed>
     */
    public function status(Server $server): array
    {
        $os = $this->detector->detect($server);
        $reason = $os->supports('firewall');
        $tool = FirewallTool::for($os);

        $base = [
            'supported' => $reason === null,
            'reason' => $reason,
            'tool' => $tool->isFirewalld() ? 'firewalld' : 'ufw',
            'installed' => false,
            'active' => false,
            'defaults' => [],
            'rules' => [],
        ];

        if ($reason !== null) {
            return $base;
        }

        $res = $this->fleet->runPrivileged($server, $tool->statusScript());
        // The sentinel proves the privileged read actually RAN (sudo/SSH ok). A sudo
        // failure emits error text (non-empty) but no sentinel — so we must not parse
        // that as an empty firewall (which would make a delete falsely "succeed").
        if (! str_contains($res['output'], 'CLUSEV_FWREAD_OK')) {
            return $base + ['readError' => true];
        }

        $s = $this->sections($res['output']);

        // ufw: a successful read ALWAYS prints a "Status:" line. If the tool is installed
        // but that line is missing, the privileged ufw call itself failed (malformed
        // config, etc.) — treat as a read error rather than an empty (no-rules) firewall,
        // so a later delete can't falsely report "already gone".
        if (! $tool->isFirewalld()
            && trim($s['inst'] ?? '') === 'yes'
            && ! preg_match('/Status:/i', $s['verbose'] ?? '')) {
            return $base + ['readError' => true];
        }

        return $tool->isFirewalld() ? $this->parseFirewalld($s, $base) : $this->parseUfw($s, $base);
    }

    /**
     * Add a firewall rule from validated parts. ufw: action/proto/port/from;
     * firewalld: opens a port in the default zone.
     *
     * @return array{ok: bool, output: string}
     */
    public function addRule(Server $server, string $action, string $proto, ?int $port, ?string $from): array
    {
        $os = $this->detector->detect($server);
        if ($reason = $os->supports('firewall')) {
            return ['ok' => false, 'output' => $reason];
        }

        // Rule mutation is ufw-only this release; firewalld is read-only.
        if ($os->firewallTool === 'firewalld') {
            return ['ok' => false, 'output' => __('backend.firewalld_read_only')];
        }

        $action = in_array($action, ['allow', 'deny', 'reject', 'limit'], true) ? $action : 'allow';
        $proto = in_array($proto, ['tcp', 'udp', 'any'], true) ? $proto : 'tcp';
        $from = ($from !== null && trim($from) !== '') ? trim($from) : null;

        if ($port !== null && ($port < 1 || $port > 65535)) {
            return ['ok' => false, 'output' => __('backend.invalid_port')];
        }
        if ($from !== null && ! $this->validIpOrCidr($from)) {
            return ['ok' => false, 'output' => __('backend.invalid_source')];
        }
        if ($port === null && $from === null) {
            return ['ok' => false, 'output' => __('backend.port_or_source_required')];
        }

        // LOCKOUT GUARD: a deny/reject that can cover the SSH connection would sever
        // every future management session. Over TCP/any, refuse it when it targets the
        // SSH port OR has NO port (a portless deny blocks ALL inbound, incl. SSH) —
        // regardless of source, since a source may include Clusev's own address. Use
        // fail2ban to block individual IPs from SSH instead.
        $sshPort = $this->sshPort($server);
        if (in_array($action, ['deny', 'reject'], true) && in_array($proto, ['tcp', 'any'], true)
            && ($port === null || $port === $sshPort)) {
            return ['ok' => false, 'output' => $port === null
                ? __('backend.deny_without_port_blocks_ssh', ['action' => $action])
                : __('backend.deny_on_ssh_port_blocks_ssh', ['action' => $action, 'port' => $sshPort])];
        }

        $tool = FirewallTool::for($os);
        if ($tool->isFirewalld() && $port === null) {
            return ['ok' => false, 'output' => __('backend.firewalld_port_required')];
        }

        $res = $this->fleet->runPrivileged($server, $tool->addRuleScript($action, $proto, $port, $from), 60);

        return ['ok' => $res['ok'], 'output' => $res['output']];
    }

    /**
     * Delete a rule. ufw: $spec is the rule's raw line content — we re-read NOW and
     * delete by the CURRENT number that matches, side-stepping the renumber race.
     * firewalld: $spec is "port:80/tcp" or "service:ssh".
     *
     * @return array{ok: bool, output: string}
     */
    public function deleteRule(Server $server, string $spec): array
    {
        $os = $this->detector->detect($server);
        if ($reason = $os->supports('firewall')) {
            return ['ok' => false, 'output' => $reason];
        }

        // Rule mutation is ufw-only this release; firewalld is read-only.
        if ($os->firewallTool === 'firewalld') {
            return ['ok' => false, 'output' => __('backend.firewalld_read_only')];
        }

        $tool = FirewallTool::for($os);
        $sshPort = $this->sshPort($server);

        // ufw: $spec is a public-method argument, so it is UNTRUSTED. Whitelist it
        // against the specs ufw itself reported (which contain no shell metacharacters)
        // — a forged spec like "allow 80/tcp; rm -rf /" can never match, so the raw
        // concatenation into `ufw delete <spec>` can never inject. Delete-by-spec is
        // also race-free (no rule number to shift).
        $status = $this->status($server);
        if (! empty($status['readError'])) {
            return ['ok' => false, 'output' => __('backend.firewall_status_read_failed')];
        }
        $known = array_column($status['rules'], 'spec');
        if (! in_array($spec, $known, true)) {
            // The rule vanished between confirm and apply — report not_found so the
            // caller does NOT audit a deletion that never happened.
            return ['ok' => true, 'notFound' => true, 'output' => __('backend.rule_gone')];
        }
        // LOCKOUT GUARD: refuse to remove an allow/limit rule that keeps the SSH port open.
        if ($this->ufwSpecOpensSsh($spec, $sshPort)) {
            return ['ok' => false, 'output' => $this->sshGuardMessage($sshPort)];
        }

        $res = $this->fleet->runPrivileged($server, $tool->deleteUfwScript($spec));

        return ['ok' => $res['ok'], 'output' => $res['output']];
    }

    /**
     * True when a ufw allow/limit spec keeps the SSH port (or an SSH app profile)
     * open. The port is matched ONLY in the destination position — the simple form
     * ("allow 22/tcp") or after "to any port" — never inside a source IP like
     * 192.0.2.22, which must stay deletable.
     */
    private function ufwSpecOpensSsh(string $spec, int $sshPort): bool
    {
        $spec = trim($spec);
        if (! preg_match('/^(allow|limit)\b/i', $spec)) {
            return false;
        }
        if (stripos($spec, 'OpenSSH') !== false || preg_match('/(^|\s)SSH(\s|$)/i', $spec)) {
            return true;
        }
        // SSH is TCP — ignore explicitly UDP-only rules (simple "<port>/udp" or "proto udp").
        if (preg_match('/^(allow|limit)\s+\d+(?::\d+)?\/udp\b/i', $spec) || preg_match('/proto\s+udp/i', $spec)) {
            return false;
        }
        // simple form: "allow 22", "allow 22/tcp", or a range "allow 20:30/tcp".
        if (preg_match('/^(allow|limit)\s+(\d+)(?::(\d+))?(\/tcp)?(\s|$)/i', $spec, $m)
            && $this->portInRange($sshPort, (int) $m[2], ($m[3] ?? '') !== '' ? (int) $m[3] : null)) {
            return true;
        }
        // destination-port form: "... port 22 ..." or a range "... port 20:30 ...".
        if (preg_match('/\bport\s+(\d+)(?::(\d+))?/i', $spec, $m)
            && $this->portInRange($sshPort, (int) $m[1], ($m[2] ?? '') !== '' ? (int) $m[2] : null)) {
            return true;
        }

        return false;
    }

    /** True when $needle equals the single port, or falls inside the [low, high] range. */
    private function portInRange(int $needle, int $low, ?int $high): bool
    {
        return $high === null ? $needle === $low : ($needle >= $low && $needle <= $high);
    }

    /** German refusal shown when a delete would sever Clusev's own SSH access. */
    private function sshGuardMessage(int $port): string
    {
        return __('backend.ssh_rule_guard', ['port' => $port]);
    }

    // NOTE: editing default policies (e.g. "deny incoming") was intentionally NOT
    // exposed — it is the highest-lockout-risk firewall operation (a restrictive
    // default with a missing/source-mismatched SSH rule severs Clusev). Defaults are
    // shown READ-ONLY in the panel; activating the firewall via the hardening toggle
    // applies safe defaults while guaranteeing ssh/80/443 stay open.

    /**
     * Detect the listening sshd Port from sshd_config (incl. drop-ins). Falls
     * back to 22 when nothing is set explicitly.
     */
    private function sshPort(Server $server): int
    {
        $res = $this->fleet->runPrivileged(
            $server,
            'grep -rhiE "^[[:space:]]*Port[[:space:]]+[0-9]+" /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ 2>/dev/null '
            .'| awk \'{print $2}\' | head -1'
        );

        $port = (int) trim($res['output']);

        return ($res['ok'] && $port >= 1 && $port <= 65535) ? $port : 22;
    }

    /**
     * @param  array<string, string>  $s
     * @param  array<string, mixed>  $base
     * @return array<string, mixed>
     */
    private function parseUfw(array $s, array $base): array
    {
        // Defaults from /etc/default/ufw — correct whether ufw is active or inactive
        // (verbose only prints the Default: line while active).
        $map = ['DROP' => 'deny', 'REJECT' => 'reject', 'ACCEPT' => 'allow'];
        $defaults = ['incoming' => null, 'outgoing' => null, 'routed' => null];
        if (preg_match('/DEFAULT_INPUT_POLICY="?(\w+)"?/', $s['defaults'] ?? '', $mi)) {
            $defaults['incoming'] = $map[strtoupper($mi[1])] ?? strtolower($mi[1]);
        }
        if (preg_match('/DEFAULT_OUTPUT_POLICY="?(\w+)"?/', $s['defaults'] ?? '', $mo)) {
            $defaults['outgoing'] = $map[strtoupper($mo[1])] ?? strtolower($mo[1]);
        }

        // Rules from `ufw show added` (add-syntax). The spec IS the delete argument.
        $rules = [];
        foreach (preg_split('/\R/', $s['added'] ?? '') ?: [] as $line) {
            if (! preg_match('/^ufw\s+(.+\S)\s*$/', trim($line), $m)) {
                continue;
            }
            $spec = trim(preg_replace('/\s+/', ' ', $m[1]) ?? '');
            // Routed/forwarding rules look like "route allow in on eth0 ..." — the action
            // is the SECOND token; keep the whole spec (delete needs "route allow …").
            $first = (string) strtok($spec, ' ');
            $routed = strcasecmp($first, 'route') === 0;
            $afterFirst = trim((string) substr($spec, strlen($first)));
            $action = strtoupper($routed ? (string) strtok($afterFirst, ' ') : $first);
            if (! in_array($action, ['ALLOW', 'DENY', 'REJECT', 'LIMIT'], true)) {
                continue;
            }
            $label = $routed ? $spec : (trim((string) substr($spec, strlen($first))) ?: $spec);
            $rules[] = ['raw' => $spec, 'label' => $label, 'action' => $action, 'from' => '', 'v6' => false, 'spec' => $spec];
        }

        $installed = trim($s['inst'] ?? '') === 'yes';

        return array_merge($base, [
            'installed' => $installed,
            'active' => (bool) preg_match('/Status:\s*active/i', $s['verbose'] ?? ''),
            // ufw stores rules in files, so they are manageable even while inactive.
            'manageable' => $installed,
            'readOnly' => false,
            'defaults' => $defaults,
            'rules' => $rules,
        ]);
    }

    /**
     * @param  array<string, string>  $s
     * @param  array<string, mixed>  $base
     * @return array<string, mixed>
     */
    private function parseFirewalld(array $s, array $base): array
    {
        // Each line is "zone|value"; the zone is shown when it is not the default zone,
        // so a port enforced in a non-default zone is attributed correctly and identical
        // rules in different zones stay distinct. firewalld is read-only, so `spec` is
        // cosmetic here (never used for deletion).
        $default = trim($s['zone'] ?? '');
        $rules = [];
        $seen = [];

        $add = function (string $line, string $kind) use (&$rules, &$seen, $default): void {
            $line = trim($line);
            if ($line === '') {
                return;
            }
            [$zone, $value] = array_pad(explode('|', $line, 2), 2, '');
            if ($value === '') {
                [$zone, $value] = [$default, $zone];
            }
            $key = $kind.'|'.$zone.'|'.$value;
            if (isset($seen[$key])) {
                return;
            }
            $seen[$key] = true;

            $suffix = ($zone !== '' && $zone !== $default) ? ' · '.$zone : '';
            $action = 'ALLOW';
            $label = $value.$suffix;
            if ($kind === 'service') {
                $label = $value.' (Dienst)'.$suffix;
            } elseif ($kind === 'rich') {
                $action = stripos($value, 'accept') !== false ? 'ALLOW'
                    : (stripos($value, 'reject') !== false ? 'REJECT' : (stripos($value, 'drop') !== false ? 'DENY' : 'ALLOW'));
            }
            $rules[] = ['label' => $label, 'action' => $action, 'from' => '', 'v6' => false, 'spec' => $kind.':'.$value];
        };

        foreach (preg_split('/\R/', $s['ports'] ?? '') ?: [] as $l) {
            $add($l, 'port');
        }
        foreach (preg_split('/\R/', $s['services'] ?? '') ?: [] as $l) {
            $add($l, 'service');
        }
        foreach (preg_split('/\R/', $s['rich'] ?? '') ?: [] as $l) {
            $add($l, 'rich');
        }

        $installed = trim($s['inst'] ?? '') === 'yes';
        $active = trim($s['active'] ?? '') === 'active';

        return array_merge($base, [
            'installed' => $installed,
            'active' => $active,
            // firewalld is READ-ONLY this release: rules are displayed (runtime state)
            // but not mutated from Clusev, so it is never "manageable" for add/delete.
            'manageable' => false,
            'readOnly' => true,
            'defaults' => ['zone' => trim($s['zone'] ?? '')],
            'rules' => $rules,
        ]);
    }

    /** Split a marker-sectioned (===CLUSEV:name===) output into [name => body]. */
    private function sections(string $out): array
    {
        $sections = [];
        $current = null;
        $buffer = [];
        foreach (preg_split('/\R/', $out) ?: [] as $line) {
            if (str_starts_with($line, '===CLUSEV:')) {
                if ($current !== null) {
                    $sections[$current] = implode("\n", $buffer);
                }
                $current = rtrim(substr($line, strlen('===CLUSEV:')), '=');
                $buffer = [];
            } elseif ($current !== null) {
                $buffer[] = $line;
            }
        }
        if ($current !== null) {
            $sections[$current] = implode("\n", $buffer);
        }

        return $sections;
    }

    /** Accept a bare IP (v4/v6) or an IP/CIDR with a valid prefix length. */
    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;
    }
}
