#!/bin/sh
# Schaltet die ninlight-Station (Rettungs- oder Migrationsmodus) an und aus.
#
# Die Station ist absichtlich nicht dauerhaft an: Solange sie laeuft, kann jeder
# in Funkreichweite mit dem Passwort einer Lampe beim naechsten Boot beliebige
# Firmware unterschieben, und der Werksreset per fuenf Stromzyklen funktioniert
# bei keiner Lampe in Reichweite mehr (die Firmware loescht den Zaehler vor
# jedem Recovery-Versuch). Der Migrationsmodus (Zugangspunkt "qconnex-firmware",
# oeffentlich bekanntes Passwort aus der alten Klyqa-Firmware) verschiebt diese
# Vertrauensgrenze noch weiter nach aussen - siehe README.md.
#
# Die beiden Zugangspunkte schliessen sich gegenseitig aus: ap_switch() faehrt
# immer erst jedes andere Stationsprofil herunter, bevor es das gewuenschte
# hochfaehrt, und prueft danach per nmcli nach, dass wirklich nur eines aktiv
# ist.
set -eu

# Ueberschreibbar fuer Tests (dasselbe Prinzip wie NINLIGHT_STATION_ROOT/_BIN/_UNIT
# in deploy.sh): so kann tests/test_cli.sh gegen ein Sandbox-Verzeichnis statt
# gegen echte Systempfade laufen.
CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf}
RUN_DIR=${NINLIGHT_RUN_DIR:-/run/ninlight-station}
STATION_LIB=${NINLIGHT_STATION_LIB:-/opt/ninlight-station}

if [ -r "$CONF" ]; then
    # shellcheck disable=SC1090
    . "$CONF"
elif [ -e "$CONF" ]; then
    # $CONF ist 0600 root:root, damit das Passwort nicht fuer jeden lesbar
    # ist. Der dokumentierte Aufruf "ssh <pi-host> ninlight-station on" laeuft
    # aber als normaler Benutzer - "sudo cat" liest die Datei ein, ohne ihre
    # Rechte aufzuweichen.
    eval "$(sudo cat "$CONF")"
else
    echo "Fehler: $CONF fehlt - zuerst tools/station/setup_pi.sh ausfuehren" >&2
    exit 1
fi

for _key in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \
            MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do
    eval "test -n \"\${$_key-}\"" || {
        echo "Fehler: $CONF: Schluessel $_key fehlt oder ist leer - tools/station/setup_pi.sh ausfuehren" >&2
        exit 1
    }
done
unset _key

need_profile() {
    # $1 = NetworkManager-Profilname, der existieren muss.
    # -F: der Profilname ist ein Konfigurationswert, kein BRE-Muster - ein Name mit
    # "." oder "[" wuerde mit "grep -x" sonst falsch (oder gar nicht) treffen.
    nmcli -t -f NAME con show | grep -Fqx "$1" || {
        echo "Fehler: NetworkManager-Profil '$1' fehlt - tools/station/setup_pi.sh ausfuehren" >&2
        exit 1
    }
}

images() {
    find "$IMAGE_DIR" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null | sort
}

migrate_images() {
    find "$IMAGE_DIR/migrate" -maxdepth 1 -name '*.img' 2>/dev/null | sort
}

# Faehrt erst jedes andere Stationsprofil herunter, dann (falls $1 nicht leer ist)
# das gewuenschte hoch, und prueft danach per nmcli nach, dass wirklich genau
# dieses Profil auf wlan0 aktiv ist. Weicht es ab, geht alles wieder herunter und
# die Funktion bricht mit Fehler ab - das ist die Absicherung fuer Fall 15 der
# Testliste: zwei gleichzeitig aktive Stationsprofile auf demselben Interface
# duerfen nie vorkommen.
#
# $1 leer bedeutet "nur alles herunterfahren" (Grundlage fuer "off").
ap_switch() {
    _target=${1:-}
    for _p in "$AP_CONNECTION" "$MIGRATE_AP_CONNECTION"; do
        if [ -n "$_target" ] && [ "$_p" = "$_target" ]; then
            continue
        fi
        sudo nmcli con down "$_p" >/dev/null 2>&1 || true
    done
    if [ -z "$_target" ]; then
        # "off": jedes "con down" oben ist mit "|| true" abgesichert, weil ein
        # haengender NetworkManager den Aufruf sonst zum Abbruch braechte, bevor
        # ueberhaupt versucht wurde, beide Profile herunterzufahren. Das darf aber
        # nicht heissen, dass ein haengengebliebenes Profil stillschweigend als
        # "aus" durchgeht: nachsehen, ob wlan0 wirklich kein Stationsprofil mehr
        # zeigt, und erst dann Erfolg melden (Fixrunde 1, I2) - sonst kann z.B. der
        # Migrations-AP mit oeffentlich bekanntem Passwort weitersenden, waehrend
        # das CLI "Station aus" behauptet.
        _active=$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2)
        if [ "$_active" = "$AP_CONNECTION" ] || [ "$_active" = "$MIGRATE_AP_CONNECTION" ]; then
            echo "Fehler: wlan0 zeigt nach dem Ausschalten immer noch '$_active' aktiv - NetworkManager pruefen (Zugangspunkt bleibt an)" >&2
            return 1
        fi
        return 0
    fi
    sudo nmcli con up "$_target" >/dev/null
    _active=$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2)
    if [ "$_active" != "$_target" ]; then
        sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true
        sudo nmcli con down "$MIGRATE_AP_CONNECTION" >/dev/null 2>&1 || true
        echo "Fehler: wlan0 zeigt nach dem Umschalten auf '$_target' unerwartet '$_active' - beide Stationsprofile wieder heruntergefahren" >&2
        return 1
    fi
}

# Schreibt $RUN_DIR/mode atomar mit 0644 (Temp-Datei im selben Verzeichnis, dann
# umbenennen) - server.py liest sie bei jedem Dienststart per --mode-file.
write_mode() {
    sudo mkdir -p "$RUN_DIR"
    _tmp="$RUN_DIR/.mode-$$"
    printf '%s' "$1" | sudo tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1
    sudo mv -f "$_tmp" "$RUN_DIR/mode"
}

# Schreibt $RUN_DIR/control.json atomar mit 0644 - dasselbe Muster (Temp-Datei im
# selben Verzeichnis anlegen, Rechte setzen, dann umbenennen) und dieselben Rechte
# wie server.py's eigenes write_control(), sonst kann der Dienst die Datei
# anschliessend nicht mehr ueberschreiben oder das CLI sie nicht mehr lesen.
#
# $1 = phase (auto|listen|armed), $2 = armed_target oder leer (-> null im JSON).
write_control() {
    sudo mkdir -p "$RUN_DIR"
    _tmp="$RUN_DIR/.control-$$.json"
    if [ -n "${2:-}" ]; then
        _json=$(printf '{"phase": "%s", "armed_target": "%s"}' "$1" "$2")
    else
        _json=$(printf '{"phase": "%s", "armed_target": null}' "$1")
    fi
    printf '%s' "$_json" | sudo tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1
    sudo mv -f "$_tmp" "$RUN_DIR/control.json"
}

# Startet (neu) und wartet kurz auf Lebenszeichen, dann prueft es nach, dass der
# eben gestartete Prozess wirklich den beabsichtigten Modus/die beabsichtigte
# Phase sieht.
#
# $1 = das gerade hochgefahrene AP-Profil (fuer Rollback bei Fehlschlag)
# $2 = erwarteter Inhalt von $RUN_DIR/mode nach dem Neustart
# $3 = erwartete Phase in control.json (nur im Migrationsmodus angeben, sonst leer)
#
# Der Grund fuer $2/$3: die systemd-Unit traegt RuntimeDirectory=ninlight-station,
# und systemd leert dieses Verzeichnis bei jedem Stop/Restart, sofern die Unit
# nicht RuntimeDirectoryPreserve=restart setzt. write_mode()/write_control() oben
# laufen VOR diesem "systemctl restart" - ein Neustart, der das Verzeichnis doch
# leert (weil die Unit die Zeile verliert, oder auf einem Pi mit alter Unit-Datei
# installiert wird), wischt also genau die Dateien wieder weg, die gerade erst
# geschrieben wurden. Der frisch gestartete Serverprozess faende dann keine
# Modusdatei und faellt laut server.py:resolve_mode() still auf "recovery"
# zurueck (bzw. read_control() auf "auto" bei fehlender Steuerdatei) - ein
# ausdrueckliches "migrate on --listen" wuerde so unbemerkt zu "Rettungsmodus,
# antwortet 404" bzw. zu "automatische Auslieferung" (Fixrunde 1, C1). Die Unit
# traegt deshalb RuntimeDirectoryPreserve=restart; diese Nachpruefung faengt es
# zusaetzlich ab, falls das je auseinanderlaeuft, statt sich allein auf die
# Unit-Datei zu verlassen.
start_and_verify() {
    _ap=$1
    _expect_mode=$2
    _expect_phase=${3:-}
    # Eine fruehere Absturzserie darf den naechsten ehrlichen Versuch nicht
    # blockieren (systemd verweigert "start"/"restart" sonst sofort mit
    # "start-limit-hit").
    sudo systemctl reset-failed ninlight-station 2>/dev/null || true
    if ! sudo systemctl restart ninlight-station; then
        # Der Neustart selbst ist gescheitert (z.B. start-limit-hit) - unter
        # set -eu wuerde das Skript hier sonst abbrechen, bevor die
        # Lebendigkeitspruefung unten je erreicht wird, und der eben
        # hochgefahrene Zugangspunkt bliebe offen ohne Server dahinter stehen.
        sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
        echo "Fehler: ninlight-station liess sich nicht (neu) starten - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2
        exit 1
    fi
    # "systemctl restart" meldet bei Type=simple schon Erfolg, sobald der Prozess
    # gestartet wurde - nicht, sobald er ueberlebt hat. Kurz warten und wirklich
    # nachsehen, sonst bleibt ein offener AP ohne Server dahinter stehen.
    sleep 1
    if ! systemctl is-active --quiet ninlight-station; then
        # Mit Restart=always laeuft ein abgestuerzter Dienst sonst im Hintergrund
        # weiter in seiner Neustartschleife - erst stoppen, dann den AP runter,
        # sonst suggeriert die Meldung unten einen saubereren Zustand als er ist.
        sudo systemctl stop ninlight-station 2>/dev/null || true
        sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
        echo "Fehler: ninlight-station ist nach dem Start nicht aktiv - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2
        exit 1
    fi
    _actual_mode=$(cat "$RUN_DIR/mode" 2>/dev/null || true)
    if [ "$_actual_mode" != "$_expect_mode" ]; then
        sudo systemctl stop ninlight-station 2>/dev/null || true
        sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
        echo "Fehler: $RUN_DIR/mode enthaelt nach dem Neustart '$_actual_mode' statt '$_expect_mode' - RuntimeDirectory wurde vermutlich geleert (RuntimeDirectoryPreserve=restart in der Unit pruefen). Zugangspunkt wieder abgeschaltet." >&2
        exit 1
    fi
    if [ -n "$_expect_phase" ]; then
        _actual_phase=$(python3 - "$RUN_DIR/control.json" <<'PY'
import json
import sys

try:
    with open(sys.argv[1]) as f:
        d = json.load(f)
except (OSError, ValueError):
    d = {}
print(d.get("phase", ""))
PY
)
        if [ "$_actual_phase" != "$_expect_phase" ]; then
            sudo systemctl stop ninlight-station 2>/dev/null || true
            sudo nmcli con down "$_ap" >/dev/null 2>&1 || true
            echo "Fehler: $RUN_DIR/control.json enthaelt nach dem Neustart Phase '$_actual_phase' statt '$_expect_phase' - RuntimeDirectory wurde vermutlich geleert. Zugangspunkt wieder abgeschaltet." >&2
            exit 1
        fi
    fi
}

recovery_on() {
    need_profile "$AP_CONNECTION"
    n=$(images | wc -l | tr -d ' ')
    [ "$n" -gt 0 ] || echo "Warnung: $IMAGE_DIR enthaelt kein Image - die Station antwortet auf alles mit 404" >&2
    ap_switch "$AP_CONNECTION"
    write_mode "recovery"
    start_and_verify "$AP_CONNECTION" "recovery"
    echo "Station an: SSID $SSID auf 192.168.8.1, $n Image(s)"
    # Mit dem oeffentlichen Default-Passwort (im Repo dokumentiert) kann jeder in
    # Funkreichweite, der es kennt, jeder Lampe beliebige Firmware unterschieben - das ist
    # die eine Haelfte der Betriebsregeln aus README.md/§9, die andere (Station wieder
    # ausschalten) steht unten.
    if [ "$PSK" = "ninlight-recovery" ]; then
        echo "Warnung: PSK ist noch der oeffentliche Default 'ninlight-recovery' - eigenes Passwort in /etc/ninlight-station.conf setzen." >&2
    fi
    echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist."
}

migrate_on() {
    # $1 = "--listen" oder leer.
    _phase=auto
    if [ "${1:-}" = "--listen" ]; then
        _phase=listen
    elif [ -n "${1:-}" ]; then
        usage
        exit 2
    fi
    need_profile "$MIGRATE_AP_CONNECTION"
    n=$(migrate_images | wc -l | tr -d ' ')
    [ "$n" -gt 0 ] || echo "Warnung: $IMAGE_DIR/migrate enthaelt keine Migrationsimages - die Station antwortet auf alles mit 404" >&2
    # Faehrt ninlight-recovery (falls es laeuft) herunter, BEVOR qconnex-firmware
    # hochkommt, und prueft danach nach, dass wirklich nur eines aktiv ist.
    ap_switch "$MIGRATE_AP_CONNECTION"
    write_mode "migration"
    write_control "$_phase" "" || {
        echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2
        exit 1
    }
    start_and_verify "$MIGRATE_AP_CONNECTION" "migration" "$_phase"
    echo "Migration an: SSID $MIGRATE_SSID auf 192.168.8.1, Phase $_phase, $n Migrationsimage(s)"
    echo "Achtung: das Passwort dieses Zugangspunkts ist aus der alten Firmware oeffentlich bekannt - siehe README.md." >&2
    echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist."
}

full_off() {
    sudo systemctl stop ninlight-station 2>/dev/null || true
    ap_switch ""
    if nmcli -t -f NAME con show | grep -Fqx "$HOME_CONNECTION"; then
        if sudo nmcli con up "$HOME_CONNECTION" >/dev/null; then
            echo "Station aus, wlan0 zurueck im Heim-WLAN ($HOME_CONNECTION)"
        else
            echo "Station aus. Zurueckschalten zu '$HOME_CONNECTION' fehlgeschlagen - wlan0-Status pruefen." >&2
        fi
    else
        echo "Station aus. Profil '$HOME_CONNECTION' fehlt, wlan0 bleibt ungenutzt - der Zugang ueber Kabel ist davon nicht betroffen." >&2
    fi
}

listen_cmd() {
    write_control "listen" "" || {
        echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2
        exit 1
    }
    echo "Phase: listen"
}

arm_cmd() {
    _target=${1:-}
    if [ -z "$_target" ]; then
        _status_file="$RUN_DIR/status.json"
        if ! _target=$(python3 - "$_status_file" <<'PY'
import json
import sys

try:
    with open(sys.argv[1]) as f:
        d = json.load(f)
except (OSError, ValueError):
    sys.exit(1)
t = (d.get("last_seen") or {}).get("target")
if not t:
    sys.exit(1)
print(t)
PY
        ); then
            echo "Fehler: kein zuletzt erkanntes Modell (last_seen) in $_status_file - Ziel angeben: ninlight-station arm <target>" >&2
            exit 1
        fi
    fi
    if ! python3 - "$STATION_LIB" "$_target" <<'PY'
import sys

sys.path.insert(0, sys.argv[1])
import products

target = sys.argv[2]
sys.exit(0 if any(p.target == target for p in products.PRODUCTS.values()) else 1)
PY
    then
        echo "Fehler: unbekanntes Ziel '$_target' - gueltige Ziele stehen in products.py" >&2
        exit 1
    fi
    write_control "armed" "$_target" || {
        echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2
        exit 1
    }
    echo "Freigegeben: $_target"
}

status_cmd() {
    # "systemctl is-active" schreibt den Zustand ("inactive", "failed", ...) auf stdout und
    # liefert dabei einen Rueckgabewert ungleich 0, sobald der Dienst nicht "active" ist -
    # ein "|| echo inaktiv" wuerde also zusaetzlich zur ersten Zeile ausgeloest und beide
    # landen in der Ausgabe. Der Zustand selbst ist die Auskunft; "status" ist damit
    # erfolgreich, sobald es ihn ermitteln konnte, unabhaengig vom Dienstzustand.
    _status_file="$RUN_DIR/status.json"
    _control_file="$RUN_DIR/control.json"
    # "existiert nicht" (Station lief noch nie) und "existiert, ist aber nicht lesbar"
    # sind zwei verschiedene Zustaende und duerfen nicht beide stillschweigend im
    # "-r"-Test verschwinden: sonst faellt ein Rechte-Fehler auf status.json (siehe
    # server.py, schreibt als root) nie auf, weil die Ausgabe genauso aussieht wie
    # "noch nie ausgeliefert". Das gilt auch fuer Modus/Erkannt unten und fuer
    # Phase/control.json weiter unten.
    if [ -r "$_status_file" ]; then
        _info=$(python3 - "$_status_file" <<'PY'
import json
import sys

try:
    with open(sys.argv[1]) as f:
        d = json.load(f)
except (OSError, ValueError):
    d = {}
last_seen = d.get("last_seen")
if last_seen:
    seen = "{} ({})".format(
        last_seen.get("product_name") or last_seen.get("target", "?"),
        last_seen.get("lamp_version", "?"),
    )
else:
    seen = "-"
print(d.get("mode", "-"))
print(seen)
PY
)
        _modus=$(printf '%s\n' "$_info" | sed -n '1p')
        _erkannt=$(printf '%s\n' "$_info" | sed -n '2p')
    elif [ -e "$_status_file" ]; then
        _modus="unbekannt (Datei nicht lesbar)"
        _erkannt="unbekannt (Datei nicht lesbar)"
    else
        _modus="-"
        _erkannt="-"
    fi
    # Die Phase kommt bewusst aus control.json, nicht aus status.json: "arm" und
    # "listen" starten den Dienst absichtlich nicht neu, also schreibt server.py's
    # write_state() status.json in der Zwischenzeit nicht neu (Fixrunde 1, I1) -
    # wer die Phase dort ablesen wuerde, saehe nach einer erfolgreichen Freigabe
    # weiterhin die Phase von vor dem letzten Dienststart und koennte "arm" fuer
    # gescheitert halten oder faelschlich glauben, nichts sei mehr scharf.
    #
    # ABER: Phase ist ein Konzept, das nur im Migrationsmodus existiert - control.json
    # kann von einem frueheren "arm"/"migrate on" liegen bleiben (RuntimeDirectoryPreserve=
    # restart aus Fixrunde 1, C1 laesst es Neustarts jetzt ausdruecklich ueberleben),
    # auch nachdem laengst wieder in den Rettungsmodus zurueckgeschaltet wurde -
    # "recovery on" raeumt die Datei bewusst nicht auf, weil der Dienst sie im
    # Rettungsmodus ohnehin ignoriert (server.py liest sie nur im Migrationsmodus).
    # Ohne diese Bedingung wuerde status nach "migrate on --listen; arm X; on" ein
    # irrefuehrendes "Phase: armed" im Rettungsmodus zeigen (Fixrunde 2, N2) - genau
    # in der sicherheitsrelevanten Dimension "scharf/nicht scharf", die I1 eigentlich
    # klarstellen sollte. Deshalb wird control.json nur gelesen, wenn Modus wirklich
    # "migration" ist; sonst ist die Phase per Definition "-", unabhaengig davon, was
    # noch in control.json steht.
    if [ "$_modus" != "migration" ]; then
        _phase="-"
    elif [ -r "$_control_file" ]; then
        _phase=$(python3 - "$_control_file" <<'PY'
import json
import sys

try:
    with open(sys.argv[1]) as f:
        d = json.load(f)
except (OSError, ValueError):
    d = {}
phase = d.get("phase", "-")
target = d.get("armed_target")
if phase == "armed" and target:
    print("{} (Ziel: {})".format(phase, target))
else:
    print(phase)
PY
)
    elif [ -e "$_control_file" ]; then
        _phase="unbekannt (Datei nicht lesbar)"
    else
        _phase="-"
    fi
    printf 'Modus  : %s\n' "$_modus"
    printf 'Phase  : %s\n' "$_phase"
    printf 'Erkannt: %s\n' "$_erkannt"
    printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)"
    printf 'wlan0  : %s\n' "$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2)"
    printf 'Adresse: %s\n' "$(ip -4 -br addr show wlan0 2>/dev/null | awk '{print $3}')"
    echo "Images :"
    images | while read -r f; do
        printf '  %-34s %s\n' "$(basename "$f")" \
            "$(python3 - "$f" <<'PY'
import pathlib, sys
t = pathlib.Path(sys.argv[1]).read_bytes()[-512:]
print(t[21:37].rstrip(b"\x00").decode("ascii", "replace") if t[:4] == b"NINL" else "kein Trailer")
PY
)"
    done
    if [ -r "$_status_file" ]; then
        echo "Zuletzt ausgeliefert:"
        sed 's/^/  /' "$_status_file"
    elif [ -e "$_status_file" ]; then
        echo "Fehler: $_status_file existiert, ist aber nicht lesbar (Rechte pruefen)" >&2
    fi
    # "status" soll mit 0 enden, sobald es seine Auskunft geben konnte, auch wenn noch nie
    # ausgeliefert wurde - ohne das explizite exit 0 waere der Exit-Code des Skripts hier
    # der des fehlgeschlagenen "-r"-Tests, weil das die letzte Anweisung im Zweig ist.
    exit 0
}

usage() {
    echo "Aufruf: ninlight-station on|recovery {on|off}|migrate {on [--listen]|off}|arm [<target>]|listen|off|status" >&2
}

case "${1:-}" in
    on)
        recovery_on
        ;;
    recovery)
        case "${2:-}" in
            on) recovery_on ;;
            off) full_off ;;
            *) usage; exit 2 ;;
        esac
        ;;
    migrate)
        case "${2:-}" in
            on) migrate_on "${3:-}" ;;
            off) full_off ;;
            *) usage; exit 2 ;;
        esac
        ;;
    arm)
        arm_cmd "${2:-}"
        ;;
    listen)
        listen_cmd
        ;;
    off)
        full_off
        ;;
    status)
        status_cmd
        ;;
    *)
        usage
        exit 2
        ;;
esac
