<?php

namespace App\Support\Os;

/**
 * Shell command strings for the host firewall (ufw or firewalld), built per OS.
 *
 * Safety model is preserved across both tools: enableScript() ALWAYS opens the
 * real sshd port + 80/443 BEFORE turning the firewall on, so Clusev can never sever
 * its own SSH session. Disable is the clean stop — never `firewall-cmd --panic-on`
 * (which would drop ALL traffic, the exact lockout the guard avoids).
 */
final class FirewallTool
{
    private const MARK = '===CLUSEV:';

    /** @param  'ufw'|'firewalld'  $tool */
    public function __construct(
        private string $tool,
        private PackageManager $pm,
        private bool $canInstall,
    ) {}

    public static function for(OsProfile $os): self
    {
        return new self(
            $os->firewallTool === 'firewalld' ? 'firewalld' : 'ufw',
            $os->managesPackages() ? PackageManager::for($os) : new PackageManager('none'),
            $os->managesPackages(),
        );
    }

    public function isFirewalld(): bool
    {
        return $this->tool === 'firewalld';
    }

    private function packageName(): string
    {
        return $this->tool === 'firewalld' ? 'firewalld' : 'ufw';
    }

    /** Guarded enable: install if needed + missing, open ssh/80/443, then turn on. */
    public function enableScript(int $sshPort): string
    {
        $ensure = $this->canInstall ? $this->pm->ensureInstalledScript($this->packageName()).' && ' : '';

        if ($this->isFirewalld()) {
            $ports = "--add-port={$sshPort}/tcp --add-port=80/tcp --add-port=443/tcp";

            // GUARD: open ssh/80/443 in the PERMANENT config BEFORE firewalld starts
            // filtering, so starting it can never drop the operator's SSH session (a
            // custom SSH port is not in the default zone). Handle both states: if the
            // daemon is already running, add to permanent + reload; if it is stopped,
            // write the permanent rules offline first, THEN start it.
            return $ensure
                .'if systemctl is-active --quiet firewalld; then '
                    ."firewall-cmd --permanent {$ports} && systemctl enable --now firewalld && firewall-cmd --reload; "
                .'else '
                    ."firewall-offline-cmd {$ports} && systemctl enable --now firewalld; "
                .'fi';
        }

        return $ensure
            ."ufw allow {$sshPort}/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable";
    }

    /** Clean disable (never a panic/drop-all). */
    public function disableScript(): string
    {
        return $this->isFirewalld() ? 'systemctl disable --now firewalld' : 'ufw disable';
    }

    /** Shell CONDITION exiting 0 when the firewall binary is present. */
    public function installedTest(): string
    {
        return $this->isFirewalld()
            ? 'command -v firewall-cmd >/dev/null 2>&1'
            : 'command -v ufw >/dev/null 2>&1';
    }

    /** Shell CONDITION exiting 0 when the firewall is currently active. */
    public function activeTest(): string
    {
        return $this->isFirewalld()
            ? 'systemctl is-active --quiet firewalld'
            : 'ufw status 2>/dev/null | grep -qi "Status: active"';
    }

    /** One privileged read of the firewall state, marker-sectioned for parsing. */
    public function statusScript(): string
    {
        if ($this->isFirewalld()) {
            // firewalld is READ-ONLY in this release (rule mutation is ufw-only), so we
            // read the RUNTIME state of the active default zone — exactly what is being
            // enforced right now — which also sidesteps any runtime/permanent zone drift.
            // Enumerate EVERY active zone (an interface/source may be bound to a
            // non-default zone), so the read-only view reflects all enforced rules.
            $zones = 'firewall-cmd --get-active-zones 2>/dev/null | grep -v "^[[:space:]]"';

            return 'echo CLUSEV_FWREAD_OK; '
                .'echo '.self::MARK.'inst===; command -v firewall-cmd >/dev/null 2>&1 && echo yes || echo no; '
                .'echo '.self::MARK.'active===; systemctl is-active --quiet firewalld && echo active || echo inactive; '
                .'echo '.self::MARK.'zone===; firewall-cmd --get-default-zone 2>/dev/null; '
                // emit "zone|value" per line so each rule keeps its zone attribution.
                .'echo '.self::MARK.'ports===; for z in $('.$zones.'); do for p in $(firewall-cmd --zone="$z" --list-ports 2>/dev/null); do echo "$z|$p"; done; done; '
                .'echo '.self::MARK.'services===; for z in $('.$zones.'); do for s in $(firewall-cmd --zone="$z" --list-services 2>/dev/null); do echo "$z|$s"; done; done; '
                .'echo '.self::MARK.'rich===; for z in $('.$zones.'); do firewall-cmd --zone="$z" --list-rich-rules 2>/dev/null | while IFS= read -r r; do [ -n "$r" ] && echo "$z|$r"; done; done';
        }

        return 'echo CLUSEV_FWREAD_OK; '
            .'echo '.self::MARK.'inst===; command -v ufw >/dev/null 2>&1 && echo yes || echo no; '
            .'echo '.self::MARK.'verbose===; ufw status verbose 2>/dev/null; '
            // `ufw show added` lists user rules in add-syntax and works whether ufw is
            // active OR inactive (status only shows rules while active); the spec
            // doubles as the delete argument, so deletes need no rule NUMBER (race-free).
            .'echo '.self::MARK.'added===; ufw show added 2>/dev/null; '
            // defaults from the config file so they are correct even when inactive.
            .'echo '.self::MARK.'defaults===; grep -E "^DEFAULT_(INPUT|OUTPUT)_POLICY" /etc/default/ufw 2>/dev/null';
    }

    /**
     * Build the ufw add command from already-validated parts (action/proto/port/from).
     * Rule mutation is ufw-only in this release; firewalld is read-only.
     */
    public function addRuleScript(string $action, string $proto, ?int $port, ?string $from): string
    {
        $spec = $action;
        if ($from !== null && $from !== '') {
            // ufw extended syntax wants `proto <p>` BEFORE the `from` clause; the
            // trailing-proto form is rejected. proto is kept even without a port so a
            // source-only rule constrains the protocol instead of allowing all.
            if ($proto !== 'any') {
                $spec .= ' proto '.$proto;
            }
            $spec .= ' from '.$from;
            if ($port !== null) {
                $spec .= ' to any port '.$port;
            }
        } elseif ($port !== null) {
            $spec .= ' '.$port.($proto !== 'any' ? '/'.$proto : '');
        }

        return 'ufw '.$spec;
    }

    /**
     * Delete a ufw rule by its add-syntax spec (e.g. "allow 80/tcp"). Race-free:
     * no rule number is involved, so a concurrent change cannot shift the target.
     * Deleting by rule does not prompt (unlike delete-by-number).
     */
    public function deleteUfwScript(string $spec): string
    {
        // Routed rules use the route grammar: "ufw route delete allow …" (NOT "ufw
        // delete route …", which ufw rejects).
        if (preg_match('/^route\s+(.+)$/i', $spec, $m)) {
            return 'ufw route delete '.$m[1];
        }

        return 'ufw delete '.$spec;
    }
}
