# shellcheck shell=sh
#
# Melden und Nachreichen — und die HTTP-Grundlage für alle anderen Abschnitte.
#
# Diese Datei kennt als Einzige den Weg zu CluPilot. Sie hält zusätzlich
# `http_get` und `http_download`, damit der Rückfall von curl auf wget an EINER
# Stelle steht: ein Rettungssystem bringt mal das eine mit und mal das andere,
# und diese Fallunterscheidung fünfmal zu schreiben heißt, sie viermal falsch zu
# schreiben.
#
# Zwei Regeln, aus denen sich alles andere ergibt:
#
#   1. `report` schreibt IMMER zuerst lokal, dann erst der Sendeversuch. Vor
#      `wireguard_joined` gibt es keinen Weg zu CluPilot (Spec §5) — die Zeilen
#      sammeln sich und werden von `flush_reports` nachgereicht.
#   2. Ein gescheiterter Sendeversuch ist NORMAL und darf den Lauf nie abbrechen.
#      Das Skript läuft unter `set -e`; jede Netzoperation hier ist deshalb
#      ausdrücklich abgefangen.
#
# Der Zeitstempel ist der des Ereignisses, nicht der der Ankunft. Sonst sieht
# eine zwanzigminütige Installation in der Konsole aus wie eine Sekunde, und
# niemand kann erkennen, welcher Abschnitt lange gedauert hat.
#
# ---------------------------------------------------------------------------
# Der Umschlag, gegen den die Plattform schreibt
# ---------------------------------------------------------------------------
#
# Die Abschnittsschlüssel sind laut Plan die einzige Absprache zwischen den zwei
# Plänen. Der Umschlag darum war nirgends festgelegt — er wird hier festgelegt,
# damit die Plattformseite EINE Stelle zum Nachlesen hat:
#
#   POST {api}/host/progress
#   Content-Type: application/json
#
#   {"code": "<einmal-code>", "events": [
#       {"section": "rescue_checked", "state": "done",
#        "message": "...", "occurred_at": "2026-07-30T18:04:11Z"}
#   ]}
#
# `state` ist `done` oder `failed`. Einen Zustand für „läuft gerade" gibt es
# absichtlich nicht: die Konsole leitet „offen" aus dem Fehlen einer Meldung ab
# (Spec §7), und ein dritter Zustand wäre eine zweite Wahrheit über dieselbe
# Sache.
#
# `message` ist immer vorhanden, notfalls leer — ein Feld, das mal da ist und
# mal nicht, ist die teurere Variante.

# Überschreibbar, damit sich die Melde-Mechanik ohne root und ohne echte
# Maschine durchspielen lässt. Im Betrieb setzt niemand die Variable: der
# Adminbereich schreibt sie nicht in die kopierte Zeile, und das Skript läuft
# ohnehin als root.
CLUPILOT_STATE_DIR="${CLUPILOT_STATE_DIR:-/var/lib/clupilot}"
CLUPILOT_PROGRESS_LOG="${CLUPILOT_STATE_DIR}/progress.jsonl"
CLUPILOT_PROGRESS_SENT="${CLUPILOT_STATE_DIR}/progress.sent"

# Kurz gehalten, weil das hier im Regelfall gegen eine Adresse läuft, die es
# noch nicht gibt. Ein langer Zeitablauf je Abschnitt summiert sich zu Minuten,
# in denen nichts passiert und niemand sieht warum.
CLUPILOT_HTTP_CONNECT_TIMEOUT=5
CLUPILOT_HTTP_MAX_TIME=15

# Legt das Zustandsverzeichnis an. 0700, weil hier später auch die
# Aufrufargumente liegen und der WireGuard-Schlüssel darin steht.
init_reporting() {
    mkdir -p "$CLUPILOT_STATE_DIR"
    chmod 700 "$CLUPILOT_STATE_DIR"
    [ -f "$CLUPILOT_PROGRESS_LOG" ] || : > "$CLUPILOT_PROGRESS_LOG"
    [ -f "$CLUPILOT_PROGRESS_SENT" ] || echo 0 > "$CLUPILOT_PROGRESS_SENT"
}

# Macht aus beliebigem Text den Rumpf eines JSON-Strings.
#
# Der Text kommt oft aus der Ausgabe eines fehlgeschlagenen Befehls, ist also
# nicht unter unserer Kontrolle. Reihenfolge zählt: der Backslash zuerst, sonst
# verdoppelt der Lauf danach die gerade gesetzten Fluchtzeichen wieder mit.
# Zeilenumbrüche und Tabulatoren werden zu Leerzeichen — eine Meldung ist eine
# Zeile, und eine eingebettete Zeile zerlegte die JSONL-Datei.
json_escape() {
    printf '%s' "$1" \
        | tr '\n\r\t' '   ' \
        | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}

# Der einzige Zeitstempel-Erzeuger im ganzen Skript. UTC mit `Z`, weil das
# Skript auf Maschinen mit jeder denkbaren Zonen-Einstellung läuft und die
# Konsole nach R19 ohnehin in UTC speichert und erst bei der Anzeige umrechnet.
now_iso() {
    date -u '+%Y-%m-%dT%H:%M:%SZ'
}

# report <section> <state> [message]
#
# Gibt immer 0 zurück. Ein Melden, das den Lauf abbricht, wäre die Diagnose, die
# den Patienten umbringt.
report() {
    _section="$1"
    _state="$2"
    _message="${3:-}"

    printf '{"section":"%s","state":"%s","message":"%s","occurred_at":"%s"}\n' \
        "$(json_escape "$_section")" \
        "$(json_escape "$_state")" \
        "$(json_escape "$_message")" \
        "$(now_iso)" \
        >> "$CLUPILOT_PROGRESS_LOG"

    log "[${_state}] ${_section}${_message:+ — ${_message}}"

    flush_reports
    return 0
}

# Reicht alles nach, was noch nicht bestätigt gesendet wurde.
#
# Der Stand steht als Zeilenzahl in einer eigenen Datei, statt die Zeilen in der
# Protokolldatei umzuschreiben: `progress.jsonl` ist damit reines Anhängen und
# übersteht einen Stromausfall mitten im Schreiben ohne Sonderbehandlung.
#
# Gibt immer 0 zurück.
flush_reports() {
    [ -f "$CLUPILOT_PROGRESS_LOG" ] || return 0

    _sent="$(cat "$CLUPILOT_PROGRESS_SENT" 2>/dev/null || echo 0)"
    _total="$(wc -l < "$CLUPILOT_PROGRESS_LOG")"
    _sent="$(printf '%s' "$_sent" | tr -cd '0-9')"
    [ -n "$_sent" ] || _sent=0

    [ "$_total" -gt "$_sent" ] || return 0

    _pending="$(sed -n "$((_sent + 1)),\$p" "$CLUPILOT_PROGRESS_LOG")"
    [ -n "$_pending" ] || return 0

    _events="$(printf '%s' "$_pending" | tr '\n' ',' | sed -e 's/,$//')"
    _body="$(printf '{"code":"%s","events":[%s]}' "$(json_escape "$CLUPILOT_CODE")" "$_events")"

    if post_json "${CLUPILOT_API}/host/progress" "$_body"; then
        # Erst nach der bestätigten Antwort. Andersherum ginge genau der
        # Abschnitt verloren, dessen Zustellung gescheitert ist.
        echo "$_total" > "$CLUPILOT_PROGRESS_SENT"
        log "nachgereicht: $((_total - _sent)) Meldung(en)"
    fi

    return 0
}

# section_done <section> — 0, wenn dieser Abschnitt schon einmal `done` gemeldet
# hat.
#
# Die Fortschrittsdatei IST der Zustand. Es gibt keine zweite Buchführung
# darüber, was schon lief — eine zweite wäre eine zweite Wahrheit, und nach dem
# Neustart in Task 3 wäre genau sie diejenige, die fehlt.
#
# Bewusst gegen den festen Rumpf geprüft, den `report` schreibt, statt gegen ein
# lose passendes Muster: `grep rescue_checked` fände auch eine Meldung, in deren
# `message` der Name zufällig vorkommt.
section_done() {
    [ -f "$CLUPILOT_PROGRESS_LOG" ] || return 1

    grep -q "^{\"section\":\"$1\",\"state\":\"done\"," "$CLUPILOT_PROGRESS_LOG"
}

# run_section <section> <funktion> — führt einen Abschnitt aus, wenn er noch
# nicht erledigt ist, und meldet sein Ergebnis.
#
# Das `if` statt `&&` ist Absicht: unter `set -e` bricht ein `&&`-Ausdruck den
# Lauf ab, bevor `report ... failed` je liefe — und dann stünde in der Konsole
# nichts, obwohl genau dieser Fall der ist, für den sie gebaut wurde.
run_section() {
    _key="$1"
    _fn="$2"

    if section_done "$_key"; then
        log "übersprungen: ${_key} (schon erledigt)"
        return 0
    fi

    if "$_fn"; then
        report "$_key" done "${CLUPILOT_SECTION_NOTE:-}"
        CLUPILOT_SECTION_NOTE=''
        return 0
    fi

    report "$_key" failed "${CLUPILOT_SECTION_NOTE:-abgebrochen in ${_key}}"
    CLUPILOT_SECTION_NOTE=''
    return 1
}

# post_json <url> <body> — 0 bei 2xx, sonst 1. Schreibt nichts nach stdout.
#
# Der Rückfall auf wget ist kein Luxus: Rettungssysteme sind unterschiedlich
# bestückt, und genau hier ist noch keine Gelegenheit gewesen, etwas
# nachzuinstallieren.
post_json() {
    _url="$1"
    _payload="$2"

    if command -v curl >/dev/null 2>&1; then
        # `-f` lässt curl bei 4xx/5xx mit Fehler enden, statt die Fehlerseite
        # als Erfolg zu behandeln.
        curl -sS -f \
            --connect-timeout "$CLUPILOT_HTTP_CONNECT_TIMEOUT" \
            --max-time "$CLUPILOT_HTTP_MAX_TIME" \
            -H 'Content-Type: application/json' \
            -H 'Accept: application/json' \
            -X POST --data "$_payload" \
            "$_url" >/dev/null 2>&1
        return $?
    fi

    if command -v wget >/dev/null 2>&1; then
        wget -q -O /dev/null \
            --timeout="$CLUPILOT_HTTP_MAX_TIME" \
            --header='Content-Type: application/json' \
            --header='Accept: application/json' \
            --post-data="$_payload" \
            "$_url"
        return $?
    fi

    return 1
}

# http_get <url> — Rumpf nach stdout, 0 bei Erfolg.
#
# Für kleine Antworten: Verzeichnislisten, Prüfsummendateien. Nicht für ISOs;
# dafür gibt es `http_download`, das nicht durch den Speicher geht.
http_get() {
    if command -v curl >/dev/null 2>&1; then
        curl -sS -fL --connect-timeout 10 --max-time 120 "$1" 2>/dev/null
        return $?
    fi

    if command -v wget >/dev/null 2>&1; then
        wget -q -O - --timeout=120 "$1" 2>/dev/null
        return $?
    fi

    return 1
}

# http_download <url> <ziel> — 0 bei Erfolg.
#
# Kein `-C -`/`--continue`: eine halb geladene ISO, die beim zweiten Lauf
# fortgesetzt wird, ergibt eine Datei mit richtiger Größe und falschem Inhalt,
# wenn sich die Quelle dazwischen geändert hat. Lieber ganz neu — die Prüfsumme
# fängt es zwar auch ab, aber erst nach dem Herunterladen.
http_download() {
    _url="$1"
    _target="$2"

    rm -f "$_target"

    if command -v curl >/dev/null 2>&1; then
        curl -sS -fL --connect-timeout 10 --max-time 3600 -o "$_target" "$_url" 2>/dev/null
        return $?
    fi

    if command -v wget >/dev/null 2>&1; then
        wget -q -O "$_target" --timeout=3600 "$_url" 2>/dev/null
        return $?
    fi

    return 1
}
