<?php
/**
 * coraza_rule_manager.php — reads/writes Coraza rule overrides + host
 * exceptions in security.db, and shells out to regenerate-coraza-overrides.sh
 * after every write.
 *
 * Source of truth: the DB tables created by
 * scripts/updates/staging/setup-coraza-rule-manager.sh.
 * On-disk artifacts (/docker/coraza/local-overrides.conf and host-exceptions/)
 * are derived; the regenerate script writes them.
 */

class coraza_rule_manager {
    private SQLite3 $db;
    private string $db_path = '/docker/whp/sql/security.db';
    private string $regen_script = '/root/whp/scripts/regenerate-coraza-overrides.sh';

    public function __construct() {
        $this->db = new SQLite3($this->db_path);
        $this->db->busyTimeout(10000);
        $this->db->exec('PRAGMA journal_mode = WAL');
    }

    public function get_rule_state(int $rule_id): ?array {
        $stmt = $this->db->prepare(
            'SELECT rule_id, state, note, updated_at, updated_by
             FROM coraza_rule_overrides WHERE rule_id = :id'
        );
        $stmt->bindValue(':id', $rule_id, SQLITE3_INTEGER);
        $row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
        return $row ?: null;
    }

    public function set_rule_state(int $rule_id, string $state, string $actor, ?string $note = null): void {
        if (!in_array($state, ['enforce', 'detect', 'off'], true)) {
            throw new InvalidArgumentException("Invalid state: $state");
        }
        $prev = $this->get_rule_state($rule_id);
        $now = time();
        $stmt = $this->db->prepare(
            'INSERT INTO coraza_rule_overrides (rule_id, state, note, updated_at, updated_by)
             VALUES (:id, :state, :note, :ts, :actor)
             ON CONFLICT(rule_id) DO UPDATE SET
                 state = excluded.state,
                 note = excluded.note,
                 updated_at = excluded.updated_at,
                 updated_by = excluded.updated_by'
        );
        $stmt->bindValue(':id', $rule_id, SQLITE3_INTEGER);
        $stmt->bindValue(':state', $state, SQLITE3_TEXT);
        $stmt->bindValue(':note', $note, $note === null ? SQLITE3_NULL : SQLITE3_TEXT);
        $stmt->bindValue(':ts', $now, SQLITE3_INTEGER);
        $stmt->bindValue(':actor', $actor, SQLITE3_TEXT);
        $stmt->execute();
        $this->audit($actor, $rule_id, 'set_state', [
            'old' => $prev['state'] ?? null,
            'new' => $state,
            'note' => $note,
        ]);
    }

    public function clear_rule_state(int $rule_id, string $actor): void {
        $prev = $this->get_rule_state($rule_id);
        $stmt = $this->db->prepare('DELETE FROM coraza_rule_overrides WHERE rule_id = :id');
        $stmt->bindValue(':id', $rule_id, SQLITE3_INTEGER);
        $stmt->execute();
        if ($prev !== null) {
            $this->audit($actor, $rule_id, 'set_state', [
                'old' => $prev['state'],
                'new' => null,
            ]);
        }
    }

    public function list_host_exceptions(int $rule_id): array {
        $stmt = $this->db->prepare(
            'SELECT hostname, note, updated_at, updated_by
             FROM coraza_rule_host_exceptions WHERE rule_id = :id ORDER BY hostname'
        );
        $stmt->bindValue(':id', $rule_id, SQLITE3_INTEGER);
        $res = $stmt->execute();
        $out = [];
        while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
            $out[] = $row;
        }
        return $out;
    }

    public function add_host_exception(int $rule_id, string $hostname, string $actor, ?string $note = null): void {
        $hostname = strtolower(trim($hostname));
        if (!preg_match('/^[a-z0-9._-]+$/', $hostname) || $hostname === '') {
            throw new InvalidArgumentException("Invalid hostname: $hostname");
        }
        $now = time();
        $stmt = $this->db->prepare(
            'INSERT OR REPLACE INTO coraza_rule_host_exceptions
             (rule_id, hostname, note, updated_at, updated_by)
             VALUES (:id, :host, :note, :ts, :actor)'
        );
        $stmt->bindValue(':id', $rule_id, SQLITE3_INTEGER);
        $stmt->bindValue(':host', $hostname, SQLITE3_TEXT);
        $stmt->bindValue(':note', $note, $note === null ? SQLITE3_NULL : SQLITE3_TEXT);
        $stmt->bindValue(':ts', $now, SQLITE3_INTEGER);
        $stmt->bindValue(':actor', $actor, SQLITE3_TEXT);
        $stmt->execute();
        $this->audit($actor, $rule_id, 'add_host', ['hostname' => $hostname, 'note' => $note]);
    }

    public function remove_host_exception(int $rule_id, string $hostname, string $actor): void {
        $hostname = strtolower(trim($hostname));
        $stmt = $this->db->prepare(
            'DELETE FROM coraza_rule_host_exceptions WHERE rule_id = :id AND hostname = :host'
        );
        $stmt->bindValue(':id', $rule_id, SQLITE3_INTEGER);
        $stmt->bindValue(':host', $hostname, SQLITE3_TEXT);
        $stmt->execute();
        $this->audit($actor, $rule_id, 'remove_host', ['hostname' => $hostname]);
    }

    public function list_all_overrides(): array {
        $res = $this->db->query(
            'SELECT rule_id, state, note, updated_at, updated_by
             FROM coraza_rule_overrides ORDER BY rule_id'
        );
        $out = [];
        while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
            $out[] = $row;
        }
        return $out;
    }

    public function list_all_host_exceptions(): array {
        $res = $this->db->query(
            'SELECT rule_id, hostname FROM coraza_rule_host_exceptions ORDER BY rule_id, hostname'
        );
        $out = [];
        while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
            $out[(int)$row['rule_id']][] = $row['hostname'];
        }
        return $out;
    }

    public function recent_audit(int $limit = 100): array {
        $stmt = $this->db->prepare(
            'SELECT id, ts, actor, rule_id, action, payload
             FROM coraza_rule_audit_log ORDER BY id DESC LIMIT :n'
        );
        $stmt->bindValue(':n', $limit, SQLITE3_INTEGER);
        $res = $stmt->execute();
        $out = [];
        while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
            $row['payload'] = json_decode($row['payload'], true);
            $out[] = $row;
        }
        return $out;
    }

    public function regenerate_and_reload(): array {
        // proc_open with argv array — no shell interpolation, no injection risk.
        $descriptor = [
            0 => ['pipe', 'r'],
            1 => ['pipe', 'w'],
            2 => ['pipe', 'w'],
        ];
        $proc = proc_open([$this->regen_script], $descriptor, $pipes);
        if (!is_resource($proc)) {
            return ['ok' => false, 'output' => 'proc_open failed'];
        }
        fclose($pipes[0]);
        $stdout = stream_get_contents($pipes[1]); fclose($pipes[1]);
        $stderr = stream_get_contents($pipes[2]); fclose($pipes[2]);
        $rc = proc_close($proc);
        return ['ok' => $rc === 0, 'output' => $stdout . $stderr];
    }

    private function audit(string $actor, int $rule_id, string $action, array $payload): void {
        $stmt = $this->db->prepare(
            'INSERT INTO coraza_rule_audit_log (ts, actor, rule_id, action, payload)
             VALUES (:ts, :actor, :id, :act, :pl)'
        );
        $stmt->bindValue(':ts', time(), SQLITE3_INTEGER);
        $stmt->bindValue(':actor', $actor, SQLITE3_TEXT);
        $stmt->bindValue(':id', $rule_id, SQLITE3_INTEGER);
        $stmt->bindValue(':act', $action, SQLITE3_TEXT);
        $stmt->bindValue(':pl', json_encode($payload), SQLITE3_TEXT);
        $stmt->execute();
    }
}
