<?php

namespace App\Services\Probes;

/**
 * Closed allow-list of probes the agent may run. The AI / operator
 * picks a probe BY KEY — they cannot send a free-form shell command.
 * Adding new probes here is the only way to expand the surface.
 *
 * Each entry defines:
 *   - description: shown in the UI + admin notes
 *   - params: typed schema, validated by Laravel's request validator
 *     before the row is queued. Keys map to {{placeholders}} in the
 *     template. Untyped or extra keys are rejected.
 *   - template: the literal shell the agent runs. Placeholders are
 *     substituted server-side (after validation) by the agent script
 *     using a SAFE substitution (no eval, no shell expansion of params).
 *     We escape every parameter with single-quote wrapping at the
 *     agent before substitution — see resources/agent/agent-probes.sh.
 *   - max_runtime: hard upper bound (seconds). The agent kills the
 *     probe if it runs longer.
 *   - max_output_bytes: output is truncated at this many bytes.
 *   - summary_fn: PHP closure (or class@method) that takes the raw
 *     output text + exit code and returns a 1-2 line synopsis the AI
 *     sees. Raw output is admin-only.
 *
 * THIS LIST IS THE SOURCE OF TRUTH. No probe runs that isn't in here.
 */
class AgentProbeRegistry
{
    /**
     * @return array<string, array{
     *   description: string,
     *   params: array<string, string>,
     *   template: string,
     *   max_runtime: int,
     *   max_output_bytes: int,
     *   summary_fn: callable,
     * }>
     */
    public static function all(): array
    {
        return [
            // ── check_disk ──────────────────────────────────────────
            'check_disk' => [
                'description'      => 'Snapshot of disk usage right now (every mounted filesystem).',
                'params'           => [],     // no parameters
                'template'         => "df -h --output=source,target,size,used,avail,pcent -x tmpfs -x devtmpfs",
                'max_runtime'      => 5,
                'max_output_bytes' => 8192,
                'summary_fn'       => [self::class, 'summariseDisk'],
            ],

            // ── check_mail_queue ────────────────────────────────────
            'check_mail_queue' => [
                'description'      => 'Current mail queue size (exim or postfix, whichever is installed).',
                'params'           => [],
                'template'         => "exim -bpc 2>/dev/null || postqueue -p 2>/dev/null | tail -1 || echo 'no mta detected'",
                'max_runtime'      => 5,
                'max_output_bytes' => 4096,
                'summary_fn'       => [self::class, 'summariseMailQueue'],
            ],

            // ── check_process_state ─────────────────────────────────
            // Parameter MUST match /^[a-z0-9_\-]{1,32}$/ so a malicious
            // process_name='mysqld; rm -rf /' is rejected at the API
            // validator. The single-quote wrap in the agent script is a
            // belt-and-suspenders second layer.
            'check_process_state' => [
                'description'      => 'Is the named process running right now?',
                'params'           => ['process_name' => 'regex:/^[a-z0-9_\-]{1,32}$/i'],
                'template'         => "pgrep -a {{process_name}} | head -5 || echo 'not running'",
                'max_runtime'      => 5,
                'max_output_bytes' => 2048,
                'summary_fn'       => [self::class, 'summariseProcess'],
            ],

            // ── check_http ──────────────────────────────────────────
            // Run from the server itself to detect DNS / firewall /
            // local-rule issues that an external probe wouldn't catch.
            // The URL host must validate against a strict regex; the
            // service layer additionally enforces the host is OWNED by
            // the ticket's customer (cross-tenant block).
            'check_http' => [
                'description'      => 'HTTP GET on a customer-owned URL from the server itself. Returns status + response time.',
                'params'           => ['url' => 'url'],
                'template'         => "curl -sS -o /dev/null -w 'status:%{http_code} time:%{time_total}s size:%{size_download}b' --max-time 10 {{url}}",
                'max_runtime'      => 12,
                'max_output_bytes' => 512,
                'summary_fn'       => [self::class, 'summariseHttp'],
            ],

            // ── check_dns_local ─────────────────────────────────────
            // What does the SERVER itself resolve this domain to?
            // Catches /etc/hosts overrides, local resolver outages,
            // weird caching — things an external DNS probe can't see.
            'check_dns_local' => [
                'description'      => 'What DNS does the server itself see for a customer-owned domain? Catches local /etc/hosts overrides.',
                'params'           => ['domain' => 'regex:/^[a-z0-9][a-z0-9\-\.]{0,254}$/i'],
                'template'         => "getent hosts {{domain}}; echo '---'; dig +short {{domain}} A 2>/dev/null; echo '---'; dig +short {{domain}} MX 2>/dev/null",
                'max_runtime'      => 6,
                'max_output_bytes' => 2048,
                'summary_fn'       => [self::class, 'summariseDnsLocal'],
            ],

            // ── security_scan_manual ────────────────────────────────
            // On-demand trigger for the daily security scanner. The
            // script itself posts findings to /api/agent/security-scan
            // — this probe only captures its stdout (a short status
            // line) so the operator sees confirmation in the UI.
            // No parameters: the script reads its own config.
            'security_scan_manual' => [
                'description'      => 'Run the daily security scan (cryptojack / persistence / mining-pool / suspicious binaries) NOW instead of waiting for 04:00 cron.',
                'params'           => [],
                'template'         => "/opt/hostseba-noc/agent-security.sh manual 2>&1 | tail -20 || echo 'scanner missing'",
                'max_runtime'      => 120,
                'max_output_bytes' => 4096,
                'summary_fn'       => [self::class, 'summariseSecurityScan'],
            ],
        ];
    }

    public static function get(string $key): ?array
    {
        return self::all()[$key] ?? null;
    }

    public static function isKnown(string $key): bool
    {
        return isset(self::all()[$key]);
    }

    // ───────────────────────── Summary functions ──────────────────
    // Each takes raw output + exit code, returns 1-2 sentences the AI
    // sees. The RAW output goes to admin notes; AI never sees raw paths
    // / hostnames / IPs that could prompt-inject.

    public static function summariseDisk(string $out, int $exit): string
    {
        if ($exit !== 0) return "disk check failed (exit {$exit})";
        $lines = preg_split("/\n+/", trim($out));
        $busy = [];
        foreach ($lines as $line) {
            // `df --output=source,target,size,used,avail,pcent` produces:
            //   Filesystem  Mounted-on  Size  Used  Avail  Use%
            //   /dev/sda1   /           100G  85G   15G    85%
            // Split on whitespace; index 1 is the mountpoint, last column
            // is the percentage. Skip header rows (where pcent isn't a number%).
            $cols = preg_split('/\s+/', trim($line));
            if (count($cols) < 6) continue;
            $pcentRaw = end($cols);
            if (! preg_match('/^(\d{1,3})%$/', $pcentRaw, $m)) continue;
            $pct = (int) $m[1];
            if ($pct < 80) continue;
            $mountpoint = $cols[1];
            $busy[] = "{$mountpoint} at {$pct}%";
        }
        return $busy
            ? 'Disks ≥80%: ' . implode(', ', array_slice($busy, 0, 5))
            : 'All disks below 80% usage.';
    }

    public static function summariseMailQueue(string $out, int $exit): string
    {
        $out = trim($out);
        if ($out === 'no mta detected') return 'No MTA detected on this server.';
        // exim -bpc returns just a number
        if (preg_match('/^\d+$/', $out)) {
            $n = (int) $out;
            return $n === 0 ? 'Mail queue is empty.' : "Mail queue: {$n} message(s).";
        }
        // postfix returns "X Kbytes in Y Requests."
        if (preg_match('/(\d+)\s+(K|M)?bytes in (\d+) Request/i', $out, $m)) {
            return "Mail queue: {$m[3]} requests, {$m[1]}{$m[2]}B.";
        }
        return mb_substr($out, 0, 120);
    }

    public static function summariseProcess(string $out, int $exit): string
    {
        $out = trim($out);
        if ($out === '' || $out === 'not running') return 'Process not running.';
        $count = substr_count($out, "\n") + 1;
        return "Process running ({$count} instance" . ($count === 1 ? '' : 's') . ").";
    }

    public static function summariseHttp(string $out, int $exit): string
    {
        if ($exit !== 0) return "HTTP request failed (exit {$exit}).";
        if (preg_match('/status:(\d+)\s+time:([\d\.]+)s/', $out, $m)) {
            return "HTTP {$m[1]} in {$m[2]}s.";
        }
        return mb_substr(trim($out), 0, 120);
    }

    public static function summariseDnsLocal(string $out, int $exit): string
    {
        $sections = explode('---', $out);
        $hosts  = trim($sections[0] ?? '');
        $aRec   = trim($sections[1] ?? '');
        $mxRec  = trim($sections[2] ?? '');
        $parts = [];
        if ($hosts) $parts[] = '/etc/hosts: ' . preg_replace('/\s+/', ' ', $hosts);
        if ($aRec)  $parts[] = 'A: ' . preg_replace('/\s+/', ',', $aRec);
        if ($mxRec) $parts[] = 'MX: ' . preg_replace('/\s+/', ',', $mxRec);
        return $parts ? implode(' · ', $parts) : 'No DNS records resolved locally.';
    }

    /**
     * Summary for the on-demand scanner. The script itself POSTs findings
     * straight to /api/agent/security-scan, so its stdout is usually
     * empty — exit 0 just means "scan completed". We surface a short
     * confirmation line for the operator.
     */
    public static function summariseSecurityScan(string $out, int $exit): string
    {
        if ($exit !== 0) return "security scan exited {$exit}";
        $out = trim($out);
        return $out === ''
            ? 'Security scan completed — see Security Center for findings.'
            : 'Security scan completed: ' . mb_substr($out, 0, 200);
    }
}
