#!/usr/bin/env bash
#
# netcheck.sh - Langzeit-Monitoring der IPv4-/IPv6-Erreichbarkeit
#
# Misst pro Durchlauf und pro Ziel jeweils fuer IPv4 und IPv6:
#   - curl-Timing (DNS, TCP-Connect, TLS-Handshake, TTFB, gesamt, HTTP-Status)
#   - mtr-Pfadmessung (Loss und Latenz pro Hop, TCP-Modus auf Port 443)
#
# Schreibt eine CSV-Zeile pro Ziel/Protokoll und legt die vollstaendige
# mtr-Ausgabe als Rohlog ab, wenn eine Messung auffaellig war.
#
# Laeuft auf macOS und Linux. Bewusst bash-3.2-kompatibel (macOS-Bordmittel).
#
# Aufruf:
#   ./netcheck.sh once              einmaliger Lauf mit Ausgabe auf stdout
#   ./netcheck.sh run               einmaliger Lauf, still (fuer cron/launchd)
#   ./netcheck.sh daemon            Endlosschleife im Abstand von INTERVAL
#   ./netcheck.sh stop              beendet einen laufenden daemon sauber
#   ./netcheck.sh summary [YYYY-MM] Auswertung der gesammelten Daten
#   ./netcheck.sh selftest          prueft Voraussetzungen und Konfiguration
#
# Version 1.2
#

set -u

# PATH explizit setzen: cron und launchd starten mit minimalem Environment,
# Homebrew-Tools (mtr) liegen dort sonst ausserhalb des Suchpfads.
PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
export PATH
export LC_ALL=C

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

# ---------------------------------------------------------------------------
# Konfiguration (per netcheck.conf neben dem Script oder per Environment)
# ---------------------------------------------------------------------------

# Zu pruefende Ziele, durch Leerzeichen getrennt. Hostnamen ohne Schema.
TARGETS="${TARGETS:-tytik.cloud}"

# Protokollfamilien: "4 6", "4" oder "6"
FAMILIES="${FAMILIES:-4 6}"

# Ablageort fuer CSV, Rohlogs und Logfile
OUTDIR="${OUTDIR:-$SCRIPT_DIR/data}"

# Wartezeit zwischen den Laeufen im daemon-Modus (Sekunden)
INTERVAL="${INTERVAL:-300}"

# mtr: Anzahl Pings pro Hop im Normalfall bzw. bei Auffaelligkeit
MTR_CYCLES="${MTR_CYCLES:-10}"
MTR_ANOMALY_CYCLES="${MTR_ANOMALY_CYCLES:-30}"

# mtr-Modus. TCP auf 443 ist am aussagekraeftigsten, weil viele CDNs
# UDP-/ICMP-Traceroute verwerfen. "-b" zeigt Hostname und IP.
MTR_MODE="${MTR_MODE:--T -P 443}"
MTR_EXTRA="${MTR_EXTRA:--b}"

# curl
CURL_METHOD="${CURL_METHOD:-HEAD}"       # HEAD oder GET
CURL_PATH="${CURL_PATH:-/}"
CURL_CONNECT_TIMEOUT="${CURL_CONNECT_TIMEOUT:-10}"
CURL_MAX_TIME="${CURL_MAX_TIME:-20}"
CURL_UA="${CURL_UA:-netcheck/1.0 (Verbindungsmonitoring)}"

# Schwellwerte, ab denen ein Lauf als auffaellig gilt und ein Rohlog entsteht
LOSS_WARN="${LOSS_WARN:-5}"              # Paketverlust zum Ziel in Prozent
RTT_WARN="${RTT_WARN:-150}"              # mittlere Latenz zum Ziel in ms
TOTAL_WARN="${TOTAL_WARN:-3000}"         # curl-Gesamtzeit in ms

# Lokalen Netz-Snapshot (Routen, Router-Advertisements, Interface-Adressen)
# bei Auffaelligkeit mitschreiben. 1 = an, 0 = aus.
SNAPSHOT_ON_ANOMALY="${SNAPSHOT_ON_ANOMALY:-0}"

# Rohlogs aelter als N Tage loeschen. 0 = nie loeschen.
RAW_RETENTION_DAYS="${RAW_RETENTION_DAYS:-30}"

# Konfigurationsdatei ueberschreibt die Defaults oben
CONF="${NETCHECK_CONF:-$SCRIPT_DIR/netcheck.conf}"
if [ -f "$CONF" ]; then
    # shellcheck disable=SC1090
    . "$CONF"
fi

# Original-stderr auf FD 9 sichern. Waehrend einer mtr-Messung ist stderr in
# die Rohdatei umgeleitet; Meldungen aus dem Signalhandler wuerden dort landen
# statt im Terminal. Ueber FD 9 erreichen sie immer den Benutzer.
exec 9>&2

CSV_DIR="$OUTDIR/csv"
RAW_DIR="$OUTDIR/raw"
LOGFILE="$OUTDIR/netcheck.log"
LOCKDIR="$OUTDIR/.lock"

CSV_HEADER="ts,epoch,runid,target,family,resolved_ip,curl_ok,curl_exit,http_code,dns_ms,connect_ms,tls_ms,ttfb_ms,total_ms,mtr_ok,mtr_loss_pct,mtr_avg_ms,mtr_worst_ms,mtr_max_hop_loss,mtr_hops,dest_reached,anomaly,reason,rawfile"

VERBOSE=0

# Laufzeitzustand fuer die Signalbehandlung
STOP=0            # 1 = Abbruch angefordert, laufende Messung verwerfen
SIGCOUNT=0        # Anzahl empfangener Abbruchsignale
CHILD_PID=""      # PID des gerade laufenden Messprozesses
SLEEP_PID=""      # PID des laufenden sleep im daemon-Modus
LOCK_HELD=0

# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------

log() {
    printf '%s %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$*" >>"$LOGFILE"
    [ "$VERBOSE" -eq 1 ] && printf '%s\n' "$*" >&9
    return 0
}

die() {
    printf 'netcheck: %s\n' "$*" >&2
    exit 1
}

# Portabler Timeout-Wrapper: macOS hat kein timeout(1).
# Aufruf: run_timeout SEKUNDEN BEFEHL [ARGS...]
run_timeout() {
    _to_secs="$1"
    shift
    "$@" &
    _to_cmd=$!
    CHILD_PID="$_to_cmd"
    ( sleep "$_to_secs"; kill -TERM "$_to_cmd" 2>/dev/null ) &
    _to_watch=$!
    wait "$_to_cmd" 2>/dev/null
    _to_rc=$?
    CHILD_PID=""
    kill -TERM "$_to_watch" 2>/dev/null
    wait "$_to_watch" 2>/dev/null
    return "$_to_rc"
}

# Sekunden (curl-Format, z.B. 0,123456 oder 0.123456) in Millisekunden
to_ms() {
    printf '%s' "$1" | awk '{ gsub(",", ".", $0); printf "%.1f", $0 * 1000 }'
}

csv_escape() {
    # Kommas und Anfuehrungszeichen aus Freitextfeldern entfernen
    printf '%s' "$1" | tr ',"' '; '
}

have() { command -v "$1" >/dev/null 2>&1; }

# ---------------------------------------------------------------------------
# Messungen
# ---------------------------------------------------------------------------

# curl-Timing. Setzt die globalen Variablen c_ok c_exit c_code c_dns c_con
# c_tls c_ttfb c_total c_ip
measure_curl() {
    _target="$1"
    _fam="$2"
    _method_opt="-I -o /dev/null"
    [ "$CURL_METHOD" = "GET" ] && _method_opt="-o /dev/null"

    _fmt='%{http_code} %{time_namelookup} %{time_connect} %{time_appconnect} %{time_starttransfer} %{time_total} %{remote_ip}'
    _out=""
    # shellcheck disable=SC2086
    _out="$(curl "-$_fam" $_method_opt -sS \
        --connect-timeout "$CURL_CONNECT_TIMEOUT" \
        --max-time "$CURL_MAX_TIME" \
        -A "$CURL_UA" \
        -w "$_fmt" \
        "https://${_target}${CURL_PATH}" 2>/dev/null)"
    c_exit=$?

    if [ -n "$_out" ]; then
        set -- $_out
        c_code="${1:-000}"
        c_dns="$(to_ms "${2:-0}")"
        c_con="$(to_ms "${3:-0}")"
        c_tls="$(to_ms "${4:-0}")"
        c_ttfb="$(to_ms "${5:-0}")"
        c_total="$(to_ms "${6:-0}")"
        c_ip="${7:-}"
    else
        c_code="000"; c_dns=""; c_con=""; c_tls=""; c_ttfb=""; c_total=""; c_ip=""
    fi

    if [ "$c_exit" -eq 0 ] && [ "$c_code" != "000" ]; then
        c_ok=1
    else
        c_ok=0
    fi
}

# mtr-Pfadmessung. Setzt m_ok m_loss m_avg m_worst m_maxhoploss m_hops
# m_dest_reached und schreibt die Rohausgabe nach "$1"
measure_mtr() {
    _target="$1"
    _fam="$2"
    _cycles="$3"
    _rawfile="$4"

    m_ok=0; m_loss=""; m_avg=""; m_worst=""; m_maxhoploss=""; m_hops=""
    m_dest_reached=0

    have mtr || { log "WARN mtr nicht gefunden - Pfadmessung uebersprungen"; return 1; }

    # mtr braucht Raw Sockets. Als root direkt, sonst passwortloses sudo.
    _mtr="mtr"
    if [ "$(id -u)" -ne 0 ]; then
        if sudo -n true 2>/dev/null; then
            _mtr="sudo -n mtr"
        fi
    fi

    # Grosszuegiger Timeout: ein Zyklus pro Sekunde plus Puffer
    _timeout=$(( _cycles + 30 ))

    # shellcheck disable=SC2086
    run_timeout "$_timeout" $_mtr "-$_fam" $MTR_MODE $MTR_EXTRA -r -w \
        -c "$_cycles" "$_target" >"$_rawfile" 2>&1
    _rc=$?

    if [ "$_rc" -ne 0 ] && [ ! -s "$_rawfile" ]; then
        log "WARN mtr -$_fam $_target fehlgeschlagen (exit $_rc)"
        return 1
    fi

    # Aus dem Report die Hop-Zeilen auswerten. Die letzten sieben Spalten sind
    # immer Loss% Snt Last Avg Best Wrst StDev - unabhaengig davon, wie breit
    # die Hostspalte ist. Deshalb wird von hinten indiziert.
    _parsed="$(awk '
        BEGIN { maxloss = 0; hops = 0; last_loss = 0; last_avg = 0; last_wrst = 0; last_host = "???" }
        /\|--/ {
            loss = $(NF-6); sub("%", "", loss);
            avg  = $(NF-3);
            wrst = $(NF-1);
            host = $2;
            hops++;
            if (loss+0 > maxloss+0) maxloss = loss;
            last_loss = loss; last_avg = avg; last_wrst = wrst; last_host = host;
        }
        END {
            if (hops == 0) { print "NA"; exit }
            reached = (last_host == "???" || last_loss+0 >= 100) ? 0 : 1;
            printf "%s %s %s %s %d %d\n", last_loss, last_avg, last_wrst, maxloss, hops, reached;
        }' "$_rawfile")"

    [ "$_parsed" = "NA" ] && { log "WARN mtr -$_fam $_target lieferte keine Hops"; return 1; }

    set -- $_parsed
    m_loss="${1:-}"; m_avg="${2:-}"; m_worst="${3:-}"; m_maxhoploss="${4:-0}"
    m_hops="${5:-0}"; m_dest_reached="${6:-0}"
    m_ok=1
    return 0
}

# Lokaler Netz-Snapshot bei Auffaelligkeit
snapshot() {
    _file="$1"
    {
        echo "=== $(date '+%Y-%m-%dT%H:%M:%S%z') lokaler Netz-Snapshot ==="
        if [ "$(uname -s)" = "Darwin" ]; then
            echo "--- scutil --nwi"; scutil --nwi 2>&1
            echo "--- netstat -rn -f inet6 | grep default"
            netstat -rn -f inet6 2>/dev/null | grep default
            echo "--- netstat -rn -f inet | grep default"
            netstat -rn -f inet 2>/dev/null | grep default
            echo "--- ndp -rn"; ndp -rn 2>&1
            echo "--- ifconfig (global)"; ifconfig 2>/dev/null | grep -E '^[a-z]|inet '
        elif have ip; then
            echo "--- ip -6 route"; ip -6 route 2>&1
            echo "--- ip -4 route"; ip -4 route 2>&1
            echo "--- ip -brief addr"; ip -brief addr 2>&1
            echo "--- ip -6 neigh"; ip -6 neigh 2>&1
        else
            echo "--- netstat -rn (iproute2 nicht vorhanden)"
            netstat -rn 2>&1
            echo "--- ifconfig"; ifconfig 2>&1 | grep -E '^[a-z]|inet'
        fi
    } >>"$_file" 2>&1
}

# ---------------------------------------------------------------------------
# Ein Durchlauf
# ---------------------------------------------------------------------------

do_run() {
    _ts="$(date '+%Y-%m-%dT%H:%M:%S%z')"
    _epoch="$(date '+%s')"
    _runid="$(date '+%Y%m%d-%H%M%S')"
    _day="$(date '+%Y-%m-%d')"
    _month="$(date '+%Y-%m')"
    _csv="$CSV_DIR/netcheck-$_month.csv"

    [ -f "$_csv" ] || printf '%s\n' "$CSV_HEADER" >"$_csv"
    mkdir -p "$RAW_DIR/$_day"

    for _target in $TARGETS; do
        for _fam in $FAMILIES; do

            [ "$STOP" -eq 1 ] && break

            measure_curl "$_target" "$_fam"

            # Wurde die Messung durch ein Abbruchsignal zerrissen, waere das
            # Ergebnis ein Scheinausfall. Solche Werte gehoeren nicht in die
            # Auswertung, deshalb wird die Messung ersatzlos verworfen.
            if [ "$STOP" -eq 1 ]; then
                log "SKIP $_target IPv$_fam waehrend Abbruch verworfen (nicht gewertet)"
                break
            fi

            _tmpraw="$RAW_DIR/$_day/.tmp-$_runid-$_target-v$_fam.txt"
            measure_mtr "$_target" "$_fam" "$MTR_CYCLES" "$_tmpraw"

            if [ "$STOP" -eq 1 ]; then
                rm -f "$_tmpraw"
                log "SKIP $_target IPv$_fam waehrend Abbruch verworfen (nicht gewertet)"
                break
            fi

            # Auffaelligkeit bewerten
            _anomaly=0
            _reason=""

            if [ "$c_ok" -ne 1 ]; then
                _anomaly=1
                _reason="curl fehlgeschlagen (exit $c_exit, http $c_code)"
            elif [ -n "$c_total" ] && [ "$(awk -v a="$c_total" -v b="$TOTAL_WARN" 'BEGIN{print (a>b)?1:0}')" = "1" ]; then
                _anomaly=1
                _reason="curl langsam (${c_total} ms)"
            fi

            case "$c_code" in
                ""|000|5*) _anomaly=1; [ -z "$_reason" ] && _reason="HTTP-Status $c_code" ;;
            esac

            if [ "$m_ok" -eq 1 ]; then
                if [ "$m_dest_reached" -ne 1 ]; then
                    _anomaly=1
                    _reason="${_reason:+$_reason; }Ziel im mtr nicht erreicht"
                fi
                if [ "$(awk -v a="$m_loss" -v b="$LOSS_WARN" 'BEGIN{print (a+0>b+0)?1:0}')" = "1" ]; then
                    _anomaly=1
                    _reason="${_reason:+$_reason; }Paketverlust ${m_loss}%"
                fi
                if [ "$(awk -v a="$m_avg" -v b="$RTT_WARN" 'BEGIN{print (a+0>b+0)?1:0}')" = "1" ]; then
                    _anomaly=1
                    _reason="${_reason:+$_reason; }Latenz ${m_avg} ms"
                fi
            elif have mtr; then
                _anomaly=1
                _reason="${_reason:+$_reason; }mtr ohne Ergebnis"
            fi

            # Rohlog nur bei Auffaelligkeit behalten, dann aber mit mehr Zyklen
            _rawname=""
            if [ "$_anomaly" -eq 1 ]; then
                _rawname="$_day/$_runid-$_target-v$_fam.txt"
                _rawpath="$RAW_DIR/$_rawname"
                {
                    echo "=== $_ts $_target IPv$_fam ==="
                    echo "Grund: $_reason"
                    echo "curl: exit=$c_exit http=$c_code ip=$c_ip dns=${c_dns}ms connect=${c_con}ms tls=${c_tls}ms ttfb=${c_ttfb}ms total=${c_total}ms"
                    echo
                    echo "--- mtr Kurzlauf ($MTR_CYCLES Zyklen) ---"
                } >"$_rawpath"
                [ -f "$_tmpraw" ] && cat "$_tmpraw" >>"$_rawpath"

                if have mtr && [ "$MTR_ANOMALY_CYCLES" -gt "$MTR_CYCLES" ]; then
                    {
                        echo
                        echo "--- mtr Nachmessung ($MTR_ANOMALY_CYCLES Zyklen) ---"
                    } >>"$_rawpath"
                    _tmpraw2="$_tmpraw.2"
                    measure_mtr "$_target" "$_fam" "$MTR_ANOMALY_CYCLES" "$_tmpraw2" || true
                    [ -f "$_tmpraw2" ] && cat "$_tmpraw2" >>"$_rawpath" && rm -f "$_tmpraw2"
                    if [ "$STOP" -eq 1 ]; then
                        echo "(Nachmessung durch Abbruchsignal beendet - unvollstaendig)" >>"$_rawpath"
                    fi
                fi

                [ "$SNAPSHOT_ON_ANOMALY" = "1" ] && snapshot "$_rawpath"

                log "ANOMALY $_target IPv$_fam: $_reason -> raw/$_rawname"
            fi
            rm -f "$_tmpraw"

            printf '%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n' \
                "$_ts" "$_epoch" "$_runid" "$_target" "$_fam" "$(csv_escape "$c_ip")" \
                "$c_ok" "$c_exit" "$c_code" \
                "$c_dns" "$c_con" "$c_tls" "$c_ttfb" "$c_total" \
                "$m_ok" "$m_loss" "$m_avg" "$m_worst" "$m_maxhoploss" "$m_hops" \
                "$m_dest_reached" "$_anomaly" "$(csv_escape "$_reason")" "$_rawname" \
                >>"$_csv" 2>/dev/null

            # Schreibfehler nicht verschlucken - eine stumm verlorene Messung
            # ist schlimmer als eine laute Fehlermeldung.
            if [ ! -w "$_csv" ]; then
                log "FEHLER CSV nicht beschreibbar: $_csv"
                printf 'netcheck: FEHLER - kann nicht nach %s schreiben.\n' "$_csv" >&9
            fi

            if [ "$VERBOSE" -eq 1 ]; then
                if [ "$_anomaly" -eq 1 ]; then
                    printf '  %-24s IPv%s  AUFFAELLIG  %s\n' "$_target" "$_fam" "$_reason"
                else
                    printf '  %-24s IPv%s  ok  http=%s ttfb=%sms loss=%s%% avg=%sms hops=%s\n' \
                        "$_target" "$_fam" "$c_code" "$c_ttfb" "${m_loss:-n/a}" "${m_avg:-n/a}" "${m_hops:-n/a}"
                fi
            fi
        done
        [ "$STOP" -eq 1 ] && break
    done

    if [ "$VERBOSE" -eq 1 ]; then
        _rows=$(( $(wc -l <"$_csv" 2>/dev/null || echo 1) - 1 ))
        printf '  -> %s Zeilen in %s\n' "$_rows" "$_csv"
    fi

    # Alte Rohlogs aufraeumen
    if [ "$RAW_RETENTION_DAYS" -gt 0 ] && [ -d "$RAW_DIR" ]; then
        find "$RAW_DIR" -type f -name '*.txt' -mtime "+$RAW_RETENTION_DAYS" -delete 2>/dev/null
        find "$RAW_DIR" -mindepth 1 -type d -empty -delete 2>/dev/null
    fi
}

# ---------------------------------------------------------------------------
# Auswertung
# ---------------------------------------------------------------------------

do_summary() {
    _month="${1:-}"
    if [ -n "$_month" ]; then
        _files="$CSV_DIR/netcheck-$_month.csv"
    else
        _files="$CSV_DIR"/netcheck-*.csv
    fi

    # shellcheck disable=SC2086
    ls $_files >/dev/null 2>&1 || die "keine CSV-Daten in $CSV_DIR gefunden"

    # shellcheck disable=SC2086
    awk -F, '
        FNR == 1 { next }
        NF < 20 { next }
        {
            ts = $1; target = $4; fam = $5;
            curl_ok = $7; total = $14;
            mtr_ok = $15; loss = $16; avg = $17;
            anomaly = $22; reason = $23;

            key = target " IPv" fam;
            if (!(key in seen)) { seen[key] = 1; order[++n] = key }

            runs[key]++;
            if (curl_ok != 1) fails[key]++;
            if (anomaly == 1) anos[key]++;

            if (curl_ok == 1 && total != "") {
                sum_total[key] += total; cnt_total[key]++;
                if (total + 0 > max_total[key] + 0) max_total[key] = total;
            }
            if (mtr_ok == 1 && avg != "") {
                sum_avg[key] += avg; cnt_avg[key]++;
                if (avg + 0 > max_avg[key] + 0) max_avg[key] = avg;
                sum_loss[key] += loss; cnt_loss[key]++;
            }

            if (anomaly == 1) {
                hour = substr(ts, 1, 13);
                hits[hour]++;
                day = substr(ts, 1, 10);
                dayhits[day]++;
                if (!(reason in rcount)) rorder[++rn] = reason;
                rcount[reason]++;
                if (first[key] == "") first[key] = ts;
                last[key] = ts;
            }
            if (window_first == "") window_first = ts;
            window_last = ts;
            total_runs++;
        }
        END {
            if (total_runs == 0) { print "Keine Datenzeilen gefunden."; exit }
            printf "Messzeitraum : %s bis %s\n", window_first, window_last;
            printf "Messungen    : %d\n\n", total_runs;

            printf "%-28s %7s %7s %8s %10s %10s %9s %9s\n", \
                "Ziel", "Laeufe", "Fehler", "Auffaell", "Ø total ms", "max total", "Ø loss %", "Ø rtt ms";
            for (i = 1; i <= n; i++) {
                k = order[i];
                at = (cnt_total[k] ? sum_total[k] / cnt_total[k] : 0);
                al = (cnt_loss[k]  ? sum_loss[k]  / cnt_loss[k]  : 0);
                ar = (cnt_avg[k]   ? sum_avg[k]   / cnt_avg[k]   : 0);
                printf "%-28s %7d %7d %8d %10.1f %10.1f %9.2f %9.1f\n", \
                    k, runs[k], fails[k]+0, anos[k]+0, at, max_total[k]+0, al, ar;
            }

            if (rn > 0) {
                printf "\nHaeufigste Ursachen\n";
                for (i = 1; i <= rn; i++)
                    printf "  %5d x  %s\n", rcount[rorder[i]], rorder[i];

                printf "\nAuffaellige Laeufe pro Tag\n";
                for (d in dayhits) daylist[++dn] = d;
                for (i = 1; i < dn; i++) for (j = i+1; j <= dn; j++)
                    if (daylist[i] > daylist[j]) { t = daylist[i]; daylist[i] = daylist[j]; daylist[j] = t }
                for (i = 1; i <= dn; i++) {
                    bar = ""; c = dayhits[daylist[i]];
                    for (b = 0; b < c && b < 60; b++) bar = bar "#";
                    printf "  %s  %4d  %s\n", daylist[i], c, bar;
                }

                printf "\nAuffaellige Laeufe pro Stunde (Tagesverteilung)\n";
                for (h in hits) { hh = substr(h, 12, 2); hourly[hh] += hits[h] }
                for (i = 0; i < 24; i++) {
                    hh = sprintf("%02d", i);
                    c = hourly[hh] + 0;
                    bar = ""; for (b = 0; b < c && b < 60; b++) bar = bar "#";
                    printf "  %s Uhr  %4d  %s\n", hh, c, bar;
                }
            } else {
                printf "\nKeine Auffaelligkeiten im Zeitraum.\n";
            }
        }' $_files
}

# ---------------------------------------------------------------------------
# Selbsttest
# ---------------------------------------------------------------------------

do_selftest() {
    echo "netcheck Selbsttest"
    echo "  System        : $(uname -s) $(uname -r)"
    echo "  Konfiguration : $([ -f "$CONF" ] && echo "$CONF" || echo "(keine, Defaults aktiv)")"
    echo "  Ziele         : $TARGETS"
    echo "  Familien      : IPv$(echo "$FAMILIES" | sed 's/ /, IPv/')"
    echo "  Datenablage   : $OUTDIR"
    echo

    _fail=0
    for _t in curl awk date find; do
        if have "$_t"; then
            echo "  ok      $_t ($(command -v "$_t"))"
        else
            echo "  FEHLT   $_t"
            _fail=1
        fi
    done

    if have mtr; then
        echo "  ok      mtr ($(command -v mtr))"
        if [ "$(id -u)" -eq 0 ]; then
            echo "  ok      laeuft als root, mtr kann Raw Sockets oeffnen"
        elif sudo -n true 2>/dev/null; then
            echo "  ok      passwortloses sudo verfuegbar, mtr wird darueber gestartet"
        else
            echo "  WARNUNG weder root noch passwortloses sudo - mtr wird scheitern."
            echo "          Entweder per root/sudo starten oder eine sudoers-Regel anlegen"
            echo "          (siehe README, Abschnitt Rechte)."
            _fail=1
        fi
    else
        echo "  FEHLT   mtr - es werden nur curl-Messungen aufgezeichnet."
        echo "          macOS: brew install mtr | Debian/Ubuntu: apt install mtr-tiny"
    fi

    echo
    for _fam in $FAMILIES; do
        for _t in $TARGETS; do
            if curl "-$_fam" -sS -I --connect-timeout 5 --max-time 10 \
                -o /dev/null "https://$_t$CURL_PATH" 2>/dev/null; then
                echo "  ok      IPv$_fam Testabruf $_t erfolgreich"
            else
                echo "  WARNUNG IPv$_fam Testabruf $_t fehlgeschlagen (genau das wuerde das Monitoring aufzeichnen)"
            fi
        done
    done

    echo
    if [ "$_fail" -eq 0 ]; then
        echo "Selbsttest ohne Blocker abgeschlossen."
    else
        echo "Selbsttest mit Problemen abgeschlossen - siehe Meldungen oben."
    fi
    return "$_fail"
}

# ---------------------------------------------------------------------------
# Signalbehandlung und Aufraeumen
# ---------------------------------------------------------------------------

# Ctrl+C bzw. SIGTERM beenden das Script nicht mitten in einer Messung, weil
# eine abgebrochene Messung als Ausfall in der CSV landen und die Auswertung
# verfaelschen wuerde. Stattdessen wird ein Stopp-Wunsch vermerkt, die
# angefangene Messung verworfen und danach sauber beendet.
# Ein zweites Signal erzwingt den sofortigen Abbruch.
on_signal() {
    SIGCOUNT=$(( SIGCOUNT + 1 ))
    STOP=1

    # Laufende Messprozesse beenden, damit der Abbruch sofort greift
    [ -n "$SLEEP_PID" ] && kill -TERM "$SLEEP_PID" 2>/dev/null
    [ -n "$CHILD_PID" ] && kill -TERM "$CHILD_PID" 2>/dev/null

    if [ "$SIGCOUNT" -ge 2 ]; then
        printf '\nnetcheck: Abbruch erzwungen.\n' >&9
        log "STOP zweites Signal - sofortiger Abbruch"
        cleanup
        exit 130
    fi

    printf '\nnetcheck: Stopp angefordert. Laufende Messung wird verworfen (nicht in die CSV geschrieben).\n' >&9
    printf 'netcheck: nochmal Ctrl+C erzwingt den sofortigen Abbruch.\n' >&9
    log "STOP Signal empfangen - laufende Messung wird verworfen"
}

cleanup() {
    [ -n "$CHILD_PID" ] && kill -TERM "$CHILD_PID" 2>/dev/null
    [ -n "$SLEEP_PID" ] && kill -TERM "$SLEEP_PID" 2>/dev/null
    # angefangene Rohlogs der abgebrochenen Messung entfernen
    rm -f "$RAW_DIR"/*/.tmp-* 2>/dev/null
    [ "$LOCK_HELD" -eq 1 ] && rm -rf "$LOCKDIR"
    return 0
}

install_traps() {
    trap 'on_signal' INT TERM
    trap 'cleanup' EXIT
}

# ---------------------------------------------------------------------------
# Sperre gegen ueberlappende Laeufe
# ---------------------------------------------------------------------------

acquire_lock() {
    if mkdir "$LOCKDIR" 2>/dev/null; then
        echo $$ >"$LOCKDIR/pid"
        LOCK_HELD=1
        install_traps
        return 0
    fi
    _pid="$(cat "$LOCKDIR/pid" 2>/dev/null)"
    if [ -n "$_pid" ] && kill -0 "$_pid" 2>/dev/null; then
        log "SKIP vorheriger Lauf (PID $_pid) laeuft noch"
        return 1
    fi
    log "WARN verwaiste Sperre von PID ${_pid:-unbekannt} entfernt"
    rm -rf "$LOCKDIR"
    mkdir "$LOCKDIR" 2>/dev/null || return 1
    echo $$ >"$LOCKDIR/pid"
    LOCK_HELD=1
    install_traps
    return 0
}

# ---------------------------------------------------------------------------
# Einstiegspunkt
# ---------------------------------------------------------------------------

mkdir -p "$CSV_DIR" "$RAW_DIR" || die "kann $OUTDIR nicht anlegen"

case "${1:-run}" in
    once)
        VERBOSE=1
        acquire_lock || exit 0
        echo "netcheck $(date '+%Y-%m-%dT%H:%M:%S%z')"
        echo "CSV:  $CSV_DIR/netcheck-$(date '+%Y-%m').csv"
        do_run
        echo "CSV: $CSV_DIR/netcheck-$(date '+%Y-%m').csv"
        ;;
    run)
        acquire_lock || exit 0
        do_run
        ;;
    daemon)
        VERBOSE=1
        acquire_lock || die "es laeuft bereits eine Instanz"
        log "START daemon, Intervall ${INTERVAL}s, Ziele: $TARGETS"
        echo "netcheck laeuft. Beenden mit Ctrl+C oder: $0 stop"
        echo "CSV:  $CSV_DIR/netcheck-$(date '+%Y-%m').csv"
        echo "Roh:  $RAW_DIR  (nur bei Auffaelligkeiten)"
        while [ "$STOP" -eq 0 ]; do
            _start="$(date '+%s')"
            do_run
            [ "$STOP" -eq 1 ] && break

            _elapsed=$(( $(date '+%s') - _start ))
            _sleep=$(( INTERVAL - _elapsed ))
            [ "$_sleep" -lt 5 ] && _sleep=5

            # Schlafen im Hintergrund, damit ein Signal sofort greift und
            # nicht bis zum Ende des Intervalls warten muss.
            sleep "$_sleep" &
            SLEEP_PID=$!
            wait "$SLEEP_PID" 2>/dev/null
            SLEEP_PID=""
        done
        log "STOP daemon beendet"
        echo "netcheck beendet."
        ;;
    stop)
        _pid="$(cat "$LOCKDIR/pid" 2>/dev/null)"
        if [ -z "$_pid" ]; then
            echo "netcheck: keine laufende Instanz gefunden (keine Sperre in $LOCKDIR)."
            exit 0
        fi
        if ! kill -0 "$_pid" 2>/dev/null; then
            echo "netcheck: verwaiste Sperre von PID $_pid entfernt."
            rm -rf "$LOCKDIR"
            exit 0
        fi
        echo "netcheck: sende Stoppsignal an PID $_pid ..."
        kill -TERM "$_pid" 2>/dev/null
        _n=0
        while [ "$_n" -lt 60 ] && kill -0 "$_pid" 2>/dev/null; do
            sleep 1
            _n=$(( _n + 1 ))
        done
        if kill -0 "$_pid" 2>/dev/null; then
            echo "netcheck: reagiert nicht, erzwinge Abbruch."
            kill -KILL "$_pid" 2>/dev/null
            sleep 1
            rm -rf "$LOCKDIR"
        else
            echo "netcheck: sauber beendet."
        fi
        ;;
    summary)
        do_summary "${2:-}"
        ;;
    selftest)
        do_selftest
        ;;
    -h|--help|help)
        sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'
        ;;
    *)
        die "unbekannter Befehl '${1}'. Bekannt: once, run, daemon, stop, summary, selftest"
        ;;
esac