<?php
declare(strict_types=1);

/**
 * Default-Timeouts: schnell genug für den Seitenaufbau.
 * (Wenn ein Dienst nicht antwortet, lieber kurz "Offline" als die ganze Seite zu blockieren.)
 */
const SERVER_STATUS_TIMEOUT_SECONDS = 0.9;
const SERVER_STATUS_TIMEOUT_GET_FALLBACK_SECONDS = 1.2;

/**
 * Serverstatus (dependency-free, mit Cache & SSRF-Guards)
 *
 * Contract:
 * - Input: allowlist targets (host/url/port)
 * - Output: array of results with state + latency
 * - Safety: no user input, no redirects, blocks private/loopback IPs
 */

/** @return bool true if IP is public-ish */
function is_public_ip(string $ip): bool
{
    // FILTER_FLAG_NO_PRIV_RANGE + NO_RES_RANGE covers most private/reserved ranges.
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
        return true;
    }

    // Fallback: treat unknown/invalid as not allowed.
    return false;
}

/** Resolve host to IPs (A/AAAA) */
function resolve_host_ips(string $host): array
{
    $ips = [];

    // IPv4
    $a = @dns_get_record($host, DNS_A);
    if (is_array($a)) {
        foreach ($a as $rec) {
            if (!empty($rec['ip'])) {
                $ips[] = $rec['ip'];
            }
        }
    }

    // IPv6
    $aaaa = @dns_get_record($host, DNS_AAAA);
    if (is_array($aaaa)) {
        foreach ($aaaa as $rec) {
            if (!empty($rec['ipv6'])) {
                $ips[] = $rec['ipv6'];
            }
        }
    }

    // Deduplicate
    $ips = array_values(array_unique(array_filter($ips)));
    return $ips;
}

function is_allowed_http_url(string $url, array $allowedHosts): bool
{
    $parts = parse_url($url);
    if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
        return false;
    }

    $scheme = strtolower((string)$parts['scheme']);
    if (!in_array($scheme, ['http', 'https'], true)) {
        return false;
    }

    $host = strtolower((string)$parts['host']);
    if (!in_array($host, $allowedHosts, true)) {
        return false;
    }

    // Resolve and ensure all resolved IPs are public.
    $ips = resolve_host_ips($host);
    if (empty($ips)) {
        return false;
    }
    foreach ($ips as $ip) {
        if (!is_public_ip($ip)) {
            return false;
        }
    }

    return true;
}

/**
 * Extract first HTTP status code from a response header array.
 * @param array<int,string> $headers
 */
function extract_http_status_code(array $headers): ?int
{
    foreach ($headers as $h) {
        if (preg_match('#^HTTP/\S+\s+(\d{3})#i', (string)$h, $m)) {
            return (int)$m[1];
        }
    }
    return null;
}

/**
 * Decide if a HTTP status code means the service is reachable.
 * Note: Redirects (3xx) are treated as not OK here because we intentionally don't follow redirects.
 * Otherwise endpoints like /nextcloud (301 -> /nextcloud/ -> 503) would look "online".
 */
function is_reachable_http_code(?int $code): bool
{
    if ($code === null) return false;

    // Explicitly DOWN: server-side failures like 503 Service Unavailable.
    if ($code >= 500 && $code < 600) return false;

    // UP: normal success
    if ($code >= 200 && $code < 300) return true;

    // UP: service is there but requires auth
    if ($code === 401 || $code === 403) return true;

    // UP: rate limited, but reachable
    if ($code === 429) return true;

    // Redirects are handled separately (we do not follow them)
    return false;
}

/**
 * Perform a lightweight HTTP request (HEAD/GET) without following redirects.
 * @return array{ok:bool, code:int|null, ms:int|null, error:string|null, headers?:array<int,string>}
 */
function check_http_request(string $url, string $method, float $timeoutSeconds = 1.6, ?string $extraHeaders = null): array
{
    $start = microtime(true);

    $http_response_header = null; // ensure local scope, avoid stale values
    $code = null;
    $error = null;

    $headers = "User-Agent: ServerStatus/1.1\r\n";
    if ($extraHeaders) {
        $headers .= $extraHeaders;
        if (!str_ends_with($headers, "\r\n")) {
            $headers .= "\r\n";
        }
    }

    $ctx = stream_context_create([
        'http' => [
            'method' => $method,
            'timeout' => $timeoutSeconds,
            'ignore_errors' => true,
            'follow_location' => 0,
            'max_redirects' => 0,
            'header' => $headers,
        ],
        'ssl' => [
            'verify_peer' => true,
            'verify_peer_name' => true,
        ],
    ]);

    set_error_handler(static function (int $severity, string $message) use (&$error): bool {
        // capture warning as error string (e.g. connection refused, timeout, DNS fail)
        $error = $message;
        return true;
    });
    $fp = @fopen($url, 'rb', false, $ctx);
    restore_error_handler();

    if ($fp !== false) {
        // For GET we don't want to download; the Range header should keep it tiny.
        fclose($fp);
    }

    $ms = (int)round((microtime(true) - $start) * 1000);

    if (isset($http_response_header) && is_array($http_response_header)) {
        $code = extract_http_status_code($http_response_header);
    }

    $ok = is_reachable_http_code($code);

    // If we couldn't even open the stream, treat as hard failure, regardless of any weird headers.
    if ($fp === false) {
        $ok = false;
        if ($error === null) {
            $error = 'Verbindung fehlgeschlagen';
        }
    }

    if ($code === null && $error === null) {
        $error = 'Lädt...er Fehler';
    }

    $res = ['ok' => $ok, 'code' => $code, 'ms' => $ms, 'error' => $error];
    if (isset($http_response_header) && is_array($http_response_header)) {
        $res['headers'] = $http_response_header;
    }

    return $res;
}

/**
 * Parallel HTTP status checks using curl_multi if available.
 * @param array<int,string> $urls
 * @return array<string,array{code:int|null, error:string|null, ms:int|null, headers:array<int,string>}> map url => result
 */
function curl_multi_head_status(array $urls, float $timeoutSeconds): array
{
    $out = [];
    if (empty($urls) || !function_exists('curl_multi_init')) {
        return $out;
    }

    $mh = curl_multi_init();
    $handles = [];

    foreach ($urls as $url) {
        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_NOBODY => true,
            CURLOPT_CUSTOMREQUEST => 'HEAD',
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HEADER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_MAXREDIRS => 0,
            CURLOPT_CONNECTTIMEOUT_MS => (int)round($timeoutSeconds * 1000),
            CURLOPT_TIMEOUT_MS => (int)round($timeoutSeconds * 1000),
            CURLOPT_USERAGENT => 'ServerStatus/1.2',
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);
        curl_multi_add_handle($mh, $ch);
        $handles[(string)$url] = $ch;
    }

    $running = null;
    do {
        $mrc = curl_multi_exec($mh, $running);
        if ($running) {
            // wait for activity (avoid busy loop)
            curl_multi_select($mh, 0.2);
        }
    } while ($running && $mrc === CURLM_OK);

    foreach ($handles as $url => $ch) {
        $code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $err = curl_error($ch);
        $totalTime = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
        $ms = is_float($totalTime) ? (int)round($totalTime * 1000) : null;

        $rawHeaders = curl_multi_getcontent($ch);
        $headers = [];
        if (is_string($rawHeaders) && $rawHeaders !== '') {
            foreach (preg_split("/\r\n|\n|\r/", trim($rawHeaders)) as $line) {
                if ($line !== '') $headers[] = $line;
            }
        }

        $out[$url] = [
            'code' => ($code > 0 ? (int)$code : null),
            'error' => ($err !== '' ? $err : null),
            'ms' => $ms,
            'headers' => $headers,
        ];

        curl_multi_remove_handle($mh, $ch);
        curl_close($ch);
    }

    curl_multi_close($mh);
    return $out;
}

/**
 * HTTP check (no redirects).
 * @return array{ok:bool, code:int|null, ms:int|null, error:string|null}
 */
function check_http_head(string $url, float $timeoutSeconds = SERVER_STATUS_TIMEOUT_SECONDS): array
{
    $head = check_http_request($url, 'HEAD', $timeoutSeconds);

    // If we got a redirect, probe the Location target once, because we don't follow redirects automatically.
    if ($head['code'] !== null && $head['code'] >= 300 && $head['code'] < 400) {
        $loc = null;
        if (!empty($head['headers']) && is_array($head['headers'])) {
            foreach ($head['headers'] as $h) {
                if (stripos((string)$h, 'Location:') === 0) {
                    $loc = trim(substr((string)$h, strlen('Location:')));
                    break;
                }
            }
        }

        if (is_string($loc) && $loc !== '') {
            // Support relative redirects
            if (str_starts_with($loc, '/')) {
                $p = parse_url($url);
                if (is_array($p) && !empty($p['scheme']) && !empty($p['host'])) {
                    $port = !empty($p['port']) ? ':' . (string)$p['port'] : '';
                    $loc = strtolower((string)$p['scheme']) . '://' . (string)$p['host'] . $port . $loc;
                }
            }

            // Probe once; no further redirects.
            $probe = check_http_request($loc, 'HEAD', $timeoutSeconds);
            if ($probe['code'] === null) {
                $probe = check_http_request($loc, 'GET', $timeoutSeconds, "Range: bytes=0-0\r\n");
            }

            if ($probe['code'] !== null && ($probe['code'] < 300 || $probe['code'] >= 400)) {
                // return the probe result (drop headers)
                if (!$probe['ok'] && empty($probe['error'])) {
                    $probe['error'] = 'HTTP ' . (string)$probe['code'];
                }
                return ['ok' => (bool)$probe['ok'], 'code' => $probe['code'], 'ms' => $probe['ms'], 'error' => $probe['error']];
            }

            return ['ok' => false, 'code' => $head['code'], 'ms' => $head['ms'], 'error' => 'Redirect'];
        }

        return ['ok' => false, 'code' => $head['code'], 'ms' => $head['ms'], 'error' => 'Redirect'];
    }

    if ($head['ok']) {
        return ['ok' => true, 'code' => $head['code'], 'ms' => $head['ms'], 'error' => null];
    }

    // If HEAD is not supported/misconfigured, a GET with Range is a cheap reachability check.
    $get = check_http_request($url, 'GET', $timeoutSeconds, "Range: bytes=0-0\r\n");

    // prefer the GET result if it produced a code (more reliable)
    if ($get['code'] !== null) {
        return ['ok' => $get['ok'], 'code' => $get['code'], 'ms' => $get['ms'], 'error' => $get['error']];
    }

    // otherwise fall back to the HEAD info
    return ['ok' => false, 'code' => $head['code'], 'ms' => $head['ms'], 'error' => $head['error']];
}

function build_server_status(array $targets): array
{
    $allowedHosts = [];
    foreach ($targets as $t) {
        if (!empty($t['url'])) {
            $p = parse_url((string)$t['url']);
            if (is_array($p) && !empty($p['host'])) {
                $allowedHosts[] = strtolower((string)$p['host']);
            }
        }
    }
    $allowedHosts = array_values(array_unique($allowedHosts));

    $results = [];

    // Attempt fast parallel HEAD checks via cURL.
    $urls = [];
    foreach ($targets as $t) {
        $u = (string)($t['url'] ?? '');
        if ($u !== '' && is_allowed_http_url($u, $allowedHosts)) {
            $urls[] = $u;
        }
    }

    $curlHeadMap = curl_multi_head_status($urls, SERVER_STATUS_TIMEOUT_SECONDS);

    foreach ($targets as $t) {
        $url = (string)($t['url'] ?? '');
        if ($url === '' || !is_allowed_http_url($url, $allowedHosts)) {
            $results[] = ['url' => $url, 'state' => 'unknown', 'ms' => null, 'detail' => 'Nicht erlaubt'];
            continue;
        }

        // If we have a curl result, use it as a quick first pass.
        if (isset($curlHeadMap[$url])) {
            $c = $curlHeadMap[$url];
            $code = $c['code'];
            $ok = is_reachable_http_code($code);

            // Handle redirects: probe location once using existing logic (stream-based) for correctness.
            if ($code !== null && $code >= 300 && $code < 400) {
                $r = check_http_head($url, SERVER_STATUS_TIMEOUT_SECONDS);
                $results[] = [
                    'url' => $url,
                    'state' => $r['ok'] ? 'up' : 'down',
                    'ms' => $r['ms'],
                    'detail' => $r['ok'] ? null : ($r['error'] ?? null),
                ];
                continue;
            }

            $results[] = [
                'url' => $url,
                'state' => $ok ? 'up' : 'down',
                'ms' => $c['ms'],
                'detail' => $ok ? null : ($c['error'] ?? ($code !== null ? ('HTTP ' . (string)$code) : null)),
            ];
            continue;
        }

        // Fallback: previous sequential stream-based check.
        $r = check_http_head($url, SERVER_STATUS_TIMEOUT_SECONDS);
        $results[] = [
            'url' => $url,
            'state' => $r['ok'] ? 'up' : 'down',
            'ms' => $r['ms'],
            'detail' => $r['ok'] ? null : ($r['error'] ?? null),
        ];
    }

    return $results;
}

