#!/bin/sh
# she-servicectl — the one privileged helper she uses to manage xyz2mqtt adapter
# instances on a host (roadmap I4, decision SV-4). Installed to /usr/local/bin and
# allowed for the she user (or the SSH user on remote hosts) via sudoers:
#
#   she ALL=(root) NOPASSWD: /usr/local/bin/she-servicectl
#
# Everything she can do on this host is what this script allows. Every argument is
# validated against a fixed pattern before anything is executed; free-form data
# (env files, install options) travels on stdin, never on the command line.
#
# Adapters are npm packages built on mqtt-interfaces-core: a template unit
# /etc/systemd/system/<adapter>@.service (fingerprint: an EnvironmentFile line for
# /etc/<adapter>/%i.env — units written by older cores lack the shared broker.env
# line, "list" reports that per adapter), instances = /etc/<adapter>/<instance>.env,
# a wrapper or symlink /usr/local/bin/<adapter>.
#
#   she-servicectl version
#   she-servicectl list                                            (adapters = template units + npm packages with mqttInterfaces; instances carry
#                                                                  systemd's MemoryCurrent / CPUUsageNSec for the dashboard)
#   she-servicectl unit   <adapter> <instance> start|stop|restart|enable|disable|status
#   she-servicectl logs   <adapter> <instance> [-n N] [--follow]
#   she-servicectl env    <adapter> <instance> read|write        (write: file on stdin)
#   she-servicectl broker-env read|write                          (write: file on stdin)
#   she-servicectl schema <adapter>
#   she-servicectl discover <adapter> [--timeout <s>] [--address <addr-or-cidr>]... [--env]   scan for devices, JSON on stdout
#                                                                     (--env: KEY=VALUE lines on stdin, for a scan that needs credentials)
#   she-servicectl install   <adapter> <instance>                 (options as KEY=VALUE lines on stdin)
#   she-servicectl uninstall <adapter> <instance>
#   she-servicectl npm    <adapter> version|origin|install|update|uninstall [--purge]   (uninstall: package, template unit; --purge also /etc and /var/lib)
#   she-servicectl files  <adapter> <instance>                     JSON listing of /etc/<adapter>/ and /var/lib/<adapter>/<instance>/
#   she-servicectl file   <adapter> <instance> read|write <path>  a file inside those two directories (write: content on stdin)
#   she-servicectl asset  <adapter> <relpath>                      a file shipped in the adapter package (example, schema)
#   she-servicectl node update [--lts|--latest|--stable|--version X.Y.Z]  install/refresh tj/n (downloaded, no npm), then n install <label|version> — JSON with the version before and after
#   she-servicectl restart-all                                     restart every running instance on this host (after a node update, say)
#   she-servicectl self-update                                     replace this script with the one on stdin (she ships it)
#   she-servicectl remove-key                                      drop the public key on stdin from the calling user's authorized_keys
#   she-servicectl teardown [--force]                              remove she from this host: that key, the sudoers rule, this script,
#                                                                  the she-services user (adapters and their instances stay untouched)
#
# Legacy units: an adapter installed the pre-core way runs as a plain <adapter>.service with its
# env file in /etc/default/<adapter>. "list" reports them under "legacy"; unit/logs/env accept "-"
# as the instance for that unit; "migrate <adapter> <name>" turns it into <adapter>@<name> via the
# adapter's own --install (which carries state such as pairing keys over) and retires the old unit.

set -eu

VERSION=15
BROKER_ENV=/etc/mqtt-interfaces/broker.env
UNIT_DIR=/etc/systemd/system

# tj/n, pinned: n is a self-contained bash script, so it is installed by downloading that one
# file — not with `npm install -g n`, which needs a node that may not be there yet and would
# drop n into whatever prefix the current node uses (where the node n installs then shadows it).
N_VERSION=10.2.0
N_BIN=/usr/local/bin/n
N_URL=https://raw.githubusercontent.com/tj/n/v$N_VERSION/bin/n

die() { printf 'she-servicectl: %s\n' "$*" >&2; exit 2; }

# ── validation ──────────────────────────────────────────────────────────────────

valid_adapter() { printf '%s' "$1" | grep -Eq '^[a-z0-9][a-z0-9._-]{0,213}$'; }
valid_instance() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9_.-]{1,64}$'; }

# a template unit written by mqtt-interfaces-core's installer: per-instance env file under /etc/<adapter>/
installed_adapter() {
    [ -f "$UNIT_DIR/$1@.service" ] && grep -Eq "^EnvironmentFile=-?/etc/$1/%i\.env" "$UNIT_DIR/$1@.service"
}

# does the template unit read the shared /etc/mqtt-interfaces/broker.env? (cores since 0.1 write it; older units do not)
unit_reads_broker_env() {
    grep -q "mqtt-interfaces/broker.env" "$UNIT_DIR/$1@.service" 2>/dev/null && printf 'true' || printf 'false'
}

need_adapter() {
    valid_adapter "$1" || die "invalid adapter name: $1"
}
need_installed() {
    need_adapter "$1"
    installed_adapter "$1" || die "no mqtt-interfaces template unit for $1"
}
need_instance() {
    valid_instance "$1" || die "invalid instance name: $1"
}

# where npm keeps globally installed packages
npm_roots() {
    r=$(command -v npm >/dev/null 2>&1 && npm root -g 2>/dev/null || true)
    printf '%s\n/usr/local/lib/node_modules\n' "$r" | grep -v '^$' | LC_ALL=C sort -u
}

# package.json of an npm-installed <adapter>: from a global root, or from wherever the command
# behind the name lives (a unit may run the package without a binary on PATH at all)
adapter_pkg_json() {
    for root in $(npm_roots); do
        if [ -f "$root/$1/package.json" ]; then printf '%s' "$root/$1/package.json"; return 0; fi
    done
    dir=$(adapter_dir "$1" 2>/dev/null) || return 1
    [ -f "$dir/package.json" ] || return 1
    printf '%s' "$dir/package.json"
}

# an mqtt-interfaces adapter: the package says so itself, either with the mqttInterfaces block
# the core reads or by depending on the core. The same question npm_adapters asks — a host runs
# plenty of other things, npm packages among them.
mqtt_interfaces_pkg() {
    pkg=$(adapter_pkg_json "$1") || return 1
    grep -q '"mqttInterfaces"' "$pkg" 2>/dev/null && return 0
    grep -q '"mqtt-interfaces-core"' "$pkg" 2>/dev/null
}

# a plain <adapter>.service of an npm-installed adapter (pre-core layout). That the unit runs the
# package or a binary of that name is not enough on its own: dnscrypt-proxy keeps its binary in
# /usr/local/bin, nut-display is an npm package — neither is an adapter.
legacy_unit() {
    [ -f "$UNIT_DIR/$1.service" ] || return 1
    grep -Eq "(/node_modules/$1/|/usr/local/bin/$1( |$))" "$UNIT_DIR/$1.service" 2>/dev/null || return 1
    mqtt_interfaces_pkg "$1"
}

# the env file of a legacy unit: its EnvironmentFile= (leading - stripped), default /etc/default/<adapter>;
# only /etc/default/<adapter> or something inside /etc/<adapter>/ is accepted
legacy_env_file() {
    f=$(sed -n 's/^EnvironmentFile=-\{0,1\}//p' "$UNIT_DIR/$1.service" 2>/dev/null | head -n 1)
    [ -n "$f" ] || f="/etc/default/$1"
    case "$f" in "/etc/default/$1"|"/etc/$1/"*) printf '%s' "$f" ;; *) return 1 ;; esac
}
# template instance (default) or, with instance "-", the legacy unit
unit_name() {
    if [ "$2" = "-" ]; then
        legacy_unit "$1" || die "no legacy unit $1.service"
        printf '%s.service' "$1"
    else
        need_installed "$1"; need_instance "$2"
        printf '%s@%s.service' "$1" "$2"
    fi
}
need_any() {
    need_adapter "$1"
    installed_adapter "$1" || legacy_unit "$1" || die "no mqtt-interfaces unit for $1"
}

# ── helpers ─────────────────────────────────────────────────────────────────────

# a JSON string, or null when the value is empty
json_str() {
    [ -n "$1" ] && printf '"%s"' "$(json_escape "$1")" || printf 'null'
}

json_escape() {
    # minimal JSON string escaping for the few free-form values (timestamps, paths)
    printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\n\r'
}

# the adapter's command: /usr/local/bin/<adapter> (wrapper or symlink), else PATH
adapter_cmd() {
    if [ -x "/usr/local/bin/$1" ]; then
        printf '/usr/local/bin/%s' "$1"
    else
        command -v "$1" 2>/dev/null || return 1
    fi
}

# node the adapter runs with (SV-17): parsed from a wrapper script, else PATH
adapter_node() {
    cmd=$(adapter_cmd "$1") || { command -v node; return; }
    node=""
    if [ ! -L "$cmd" ]; then
        node=$(grep -o '[^" ]*/bin/node' "$cmd" 2>/dev/null | head -n 1 || true)
    fi
    if [ -n "$node" ] && [ -x "$node" ]; then
        printf '%s' "$node"
    else
        command -v node
    fi
}

adapter_npm() {
    node=$(adapter_node "$1")
    npm="$(dirname "$node")/npm"
    if [ -x "$npm" ]; then printf '%s' "$npm"; else command -v npm; fi
}

# where the adapter's package lives (resolved through wrapper/symlink)
adapter_dir() {
    cmd=$(adapter_cmd "$1") || return 1
    if [ -L "$cmd" ]; then
        target=$(readlink -f "$cmd")
    else
        target=$(grep -o '[^" ]*\.js' "$cmd" 2>/dev/null | head -n 1 || true)
        [ -n "$target" ] || target="$cmd"
    fi
    d=$(dirname "$target")
    while [ "$d" != "/" ]; do
        if [ -f "$d/package.json" ]; then printf '%s' "$d"; return 0; fi
        d=$(dirname "$d")
    done
    return 1
}

adapter_version() {
    cmd=$(adapter_cmd "$1") || { printf 'null'; return; }
    v=$("$cmd" --version 2>/dev/null | tail -n 1 || true)
    if [ -n "$v" ]; then printf '"%s"' "$(json_escape "$v")"; else printf 'null'; fi
}

# registry: the global npm tree knows the package; manual: deployed by tarball (SV-16)
# registry: installed with npm install -g. Older npms list global packages in the hidden lockfile of the
# global root; npm 11 keeps none there. A manual deploy (tarball extracted by deploy.sh, then
# npm install --prefix <dir>) leaves a hidden lockfile *inside* the package — a registry install does not.
adapter_origin() {
    npm=$(adapter_npm "$1")
    root=$("$npm" root -g 2>/dev/null || true)
    lock="$root/.package-lock.json"
    if [ -n "$root" ] && [ -f "$lock" ] && grep -q "\"node_modules/$1\"" "$lock"; then
        printf 'registry'
        return
    fi
    dir=$(adapter_dir "$1" 2>/dev/null || true)
    if [ -n "$dir" ] && [ -f "$dir/node_modules/.package-lock.json" ]; then
        printf 'manual'
    else
        printf 'registry'
    fi
}

adapter_user() {
    if id -u "$1" >/dev/null 2>&1; then printf '%s' "$1"; else printf 'root'; fi
}

# absolute path without control characters or ..
valid_path() { printf '%s' "$1" | grep -Eq '^/[^[:cntrl:]]+$' && ! printf '%s' "$1" | grep -q '\.\.'; }

# the two directories she may read and write instance files in
managed_dir() {
    case "$3" in
        etc) printf '/etc/%s' "$1" ;;
        state) printf '/var/lib/%s/%s' "$1" "$2" ;;
    esac
}

# is $3 (an existing file, or a new file whose directory exists) inside the managed dirs of $1/$2?
inside_managed() {
    if [ -e "$3" ]; then
        real=$(realpath "$3" 2>/dev/null) || return 1
    else
        pdir=$(realpath "$(dirname "$3")" 2>/dev/null) || return 1
        real="$pdir/$(basename "$3")"
    fi
    for d in "$(managed_dir "$1" "$2" etc)" "$(managed_dir "$1" "$2" state)"; do
        rd=$(realpath "$d" 2>/dev/null) || continue
        case "$real" in "$rd"/*) return 0 ;; esac
    done
    return 1
}

# validate an env file on stdin (KEY=VALUE, comments, blank lines) into $1
read_env_stdin() {
    tmp=$1
    cat > "$tmp"
    if grep -Evq '^([A-Z][A-Z0-9_]*=.*|#.*|[[:space:]]*)$' "$tmp"; then
        rm -f "$tmp"
        die "env file: only KEY=VALUE lines and comments are allowed"
    fi
}

# ── commands ────────────────────────────────────────────────────────────────────

# ── adapters installed with npm but without a template unit yet ─────────────────
# (a catalog install is `npm install -g <adapter>`; the unit appears with the first `--install`)
npm_adapters() {
    for root in $(npm_roots); do
        for pkg in "$root"/*/package.json; do
            [ -f "$pkg" ] || continue
            a=$(basename "$(dirname "$pkg")")
            valid_adapter "$a" || continue
            grep -q '"mqttInterfaces"' "$pkg" 2>/dev/null || continue
            adapter_cmd "$a" >/dev/null 2>&1 || continue
            printf '%s\n' "$a"
        done
    done | LC_ALL=C sort -u
}

cmd_version() {
    printf '%s\n' "$VERSION"
}

cmd_list() {
    hostname=$(hostname 2>/dev/null || uname -n)
    node=$(command -v node >/dev/null 2>&1 && node --version 2>/dev/null || true)
    # the machine type decides which node builds exist for this host (an old Pi has none
    # past the armv6l/armv7l lines) — she reads it off nodejs.org's index per architecture
    arch=$(uname -m 2>/dev/null || true)
    printf '{"helper":%s,"hostname":"%s","node":%s,"arch":%s,"brokerEnv":%s,"adapters":[' \
        "$VERSION" "$(json_escape "$hostname")" \
        "$([ -n "$node" ] && printf '"%s"' "$node" || printf 'null')" \
        "$([ -n "$arch" ] && printf '"%s"' "$(json_escape "$arch")" || printf 'null')" \
        "$([ -f "$BROKER_ENV" ] && printf 'true' || printf 'false')"
    first=1
    adapters=""
    legacy=""
    for unit in "$UNIT_DIR"/*.service; do
        [ -f "$unit" ] || continue
        a=$(basename "$unit" .service)
        case "$a" in *@) continue ;; esac
        valid_adapter "$a" || continue
        legacy_unit "$a" || continue
        installed_adapter "$a" && continue # the template unit lists it below
        legacy="$legacy $a"
    done
    for unit in "$UNIT_DIR"/*@.service $(for a in $legacy; do printf '%s ' "$UNIT_DIR/$a@.service.legacy-placeholder"; done); do
        case "$unit" in *.legacy-placeholder) a=$(basename "$unit" @.service.legacy-placeholder) ;; *) [ -f "$unit" ] || continue; a=$(basename "$unit" @.service); installed_adapter "$a" || continue ;; esac
        valid_adapter "$a" || continue
        adapters="$adapters $a"
        [ $first -eq 1 ] || printf ','
        first=0
        dir=$(adapter_dir "$a" 2>/dev/null || true)
        printf '{"name":"%s","version":%s,"origin":"%s","path":%s,"node":"%s","brokerEnv":%s}' \
            "$a" "$(adapter_version "$a")" "$(adapter_origin "$a")" \
            "$([ -n "$dir" ] && printf '"%s"' "$(json_escape "$dir")" || printf 'null')" \
            "$(json_escape "$(adapter_node "$a")")" "$(unit_reads_broker_env "$a")"
    done
    for a in $(npm_adapters); do
        case " $adapters " in *" $a "*) continue ;; esac
        [ $first -eq 1 ] || printf ','
        first=0
        dir=$(adapter_dir "$a" 2>/dev/null || true)
        printf '{"name":"%s","version":%s,"origin":"%s","path":%s,"node":"%s","brokerEnv":false,"unit":false}' \
            "$a" "$(adapter_version "$a")" "$(adapter_origin "$a")" \
            "$([ -n "$dir" ] && printf '"%s"' "$(json_escape "$dir")" || printf 'null')" \
            "$(json_escape "$(adapter_node "$a")")"
    done
    printf '],"legacy":['
    first=1
    seenlegacy=""
    for a in $legacy $(for a in $adapters; do legacy_unit "$a" && printf '%s ' "$a"; done | tr ' ' '\n' | LC_ALL=C sort -u); do
        legacy_unit "$a" || continue
        case " $seenlegacy " in *" $a "*) continue ;; esac
        seenlegacy="$seenlegacy $a"
        [ $first -eq 1 ] || printf ','
        first=0
        show=$(systemctl show -p ActiveState -p SubState -p UnitFileState -p ExecMainStartTimestamp -p NRestarts "$a.service" 2>/dev/null || true)
        active=$(printf '%s\n' "$show" | sed -n 's/^ActiveState=//p')
        sub=$(printf '%s\n' "$show" | sed -n 's/^SubState=//p')
        uf=$(printf '%s\n' "$show" | sed -n 's/^UnitFileState=//p')
        since=$(printf '%s\n' "$show" | sed -n 's/^ExecMainStartTimestamp=//p')
        nr=$(printf '%s\n' "$show" | sed -n 's/^NRestarts=//p')
        envf=$(legacy_env_file "$a" 2>/dev/null || true)
        printf '{"adapter":"%s","unit":"%s.service","active":"%s","sub":"%s","unitFile":"%s","since":"%s","restarts":%s,"envFile":%s}' \
            "$a" "$a" "$(json_escape "$active")" "$(json_escape "$sub")" "$(json_escape "$uf")" \
            "$(json_escape "$since")" "${nr:-0}" "$([ -n "$envf" ] && printf '"%s"' "$(json_escape "$envf")" || printf 'null')"
    done
    printf '],"instances":['
    first=1
    for a in $adapters; do
        installed_adapter "$a" || continue
        for envf in "/etc/$a"/*.env; do
            [ -f "$envf" ] || continue
            i=$(basename "$envf" .env)
            valid_instance "$i" || continue
            [ $first -eq 1 ] || printf ','
            first=0
            show=$(systemctl show -p ActiveState -p SubState -p UnitFileState -p ExecMainStartTimestamp -p NRestarts -p MainPID -p MemoryCurrent -p CPUUsageNSec "$a@$i.service" 2>/dev/null || true)
            active=$(printf '%s\n' "$show" | sed -n 's/^ActiveState=//p')
            pid=$(printf '%s\n' "$show" | sed -n 's/^MainPID=//p')
            mem=$(printf '%s\n' "$show" | sed -n 's/^MemoryCurrent=//p')
            cpuns=$(printf '%s\n' "$show" | sed -n 's/^CPUUsageNSec=//p')
            case "$mem" in *[!0-9]*|'') mem=null ;; esac
            case "$cpuns" in *[!0-9]*|'') cpuns=null ;; esac
            sub=$(printf '%s\n' "$show" | sed -n 's/^SubState=//p')
            uf=$(printf '%s\n' "$show" | sed -n 's/^UnitFileState=//p')
            since=$(printf '%s\n' "$show" | sed -n 's/^ExecMainStartTimestamp=//p')
            nr=$(printf '%s\n' "$show" | sed -n 's/^NRestarts=//p')
            printf '{"adapter":"%s","instance":"%s","active":"%s","sub":"%s","unitFile":"%s","since":"%s","restarts":%s,"pid":%s,"memory":%s,"cpuNs":%s}' \
                "$a" "$i" "$(json_escape "$active")" "$(json_escape "$sub")" "$(json_escape "$uf")" \
                "$(json_escape "$since")" "${nr:-0}" "${pid:-0}" "$mem" "$cpuns"
        done
    done
    printf ']}\n'
}

cmd_unit() {
    [ $# -eq 3 ] || die "usage: unit <adapter> <instance|-> <action>"
    need_adapter "$1"
    u=$(unit_name "$1" "$2")
    case "$3" in
        start|stop|restart|enable|disable) systemctl "$3" "$u" ;;
        status) systemctl show -p ActiveState -p SubState -p UnitFileState -p ExecMainStartTimestamp -p NRestarts -p MainPID "$u" ;;
        *) die "unknown unit action: $3" ;;
    esac
}

cmd_logs() {
    [ $# -ge 2 ] || die "usage: logs <adapter> <instance|-> [-n N] [--follow]"
    need_adapter "$1"
    unit=$(unit_name "$1" "$2"); shift 2
    lines=200; follow=0
    while [ $# -gt 0 ]; do
        case "$1" in
            -n) shift; printf '%s' "${1:-}" | grep -Eq '^[0-9]{1,5}$' || die "-n needs a number"; lines=$1 ;;
            --follow) follow=1 ;;
            *) die "unknown logs option: $1" ;;
        esac
        shift
    done
    if [ $follow -eq 1 ]; then
        exec journalctl -u "$unit" -n "$lines" -o json --no-pager -f
    else
        exec journalctl -u "$unit" -n "$lines" -o json --no-pager
    fi
}

cmd_env() {
    [ $# -eq 3 ] || die "usage: env <adapter> <instance|-> read|write"
    need_adapter "$1"
    if [ "$2" = "-" ]; then
        legacy_unit "$1" || die "no legacy unit $1.service"
        f=$(legacy_env_file "$1") || die "legacy env file of $1 is outside /etc/default and /etc/$1"
    else
        need_installed "$1"; need_instance "$2"
        f="/etc/$1/$2.env"
    fi
    case "$3" in
        read)
            [ -f "$f" ] || die "no such instance: $1@$2"
            cat "$f" ;;
        write)
            [ -f "$f" ] || die "no such instance: $1@$2 (use install)"
            tmp=$(mktemp "$(dirname "$f")/.$(basename "$f").XXXXXX")
            read_env_stdin "$tmp"
            cp -p "$f" "$f.bak"
            chown "root:$(adapter_user "$1")" "$tmp"
            chmod 0640 "$tmp"
            mv "$tmp" "$f"
            printf 'wrote %s\n' "$f" ;;
        *) die "unknown env action: $3" ;;
    esac
}

cmd_broker_env() {
    [ $# -eq 1 ] || die "usage: broker-env read|write"
    case "$1" in
        read)
            if [ -f "$BROKER_ENV" ]; then cat "$BROKER_ENV"; fi ;;
        write)
            mkdir -p "$(dirname "$BROKER_ENV")"
            tmp=$(mktemp "$(dirname "$BROKER_ENV")/.broker.env.XXXXXX")
            read_env_stdin "$tmp"
            [ -f "$BROKER_ENV" ] && cp -p "$BROKER_ENV" "$BROKER_ENV.bak"
            chown root:root "$tmp"
            chmod 0640 "$tmp"
            mv "$tmp" "$BROKER_ENV"
            printf 'wrote %s\n' "$BROKER_ENV" ;;
        *) die "unknown broker-env action: $1" ;;
    esac
}

cmd_schema() {
    [ $# -eq 1 ] || die "usage: schema <adapter>"
    need_adapter "$1"
    cmd=$(adapter_cmd "$1") || die "adapter not installed: $1"
    exec "$cmd" --config-schema
}

# The adapter's own --discover (core 0.9+): it owns the hint, the protocols and the rate limiting.
# Runs on this host on purpose — broadcast, multicast, ARP and /dev only reach the network and the
# usb bus of the machine the scan runs on, which is this one, not necessarily she's.
#
# --env (v12) takes KEY=VALUE lines on stdin for a scan that cannot run without them: a cloud hint
# (core 0.11+) lists a vendor account rather than scanning, so it needs the login, which the core
# names in x-discover-needs. Same channel install already uses — never argv, where a process list
# would show it — and the temp file is gone before the adapter is exec'd.
cmd_discover() {
    [ $# -ge 1 ] || die "usage: discover <adapter> [--timeout <s>] [--address <addr>]... [--env]"
    need_adapter "$1"
    cmd=$(adapter_cmd "$1") || die "adapter not installed: $1"
    shift
    set -- --discover --discover-json "$@"
    args=""
    env_stdin=0
    while [ $# -gt 0 ]; do
        case "$1" in
            --discover|--discover-json) args="$args $1"; shift ;;
            --env) env_stdin=1; shift ;;
            --timeout)
                [ $# -ge 2 ] || die "--timeout needs a value"
                case "$2" in ''|*[!0-9]*) die "invalid timeout: $2" ;; esac
                args="$args --discover-timeout $2"; shift 2 ;;
            --address)
                [ $# -ge 2 ] || die "--address needs a value"
                # an address or a cidr range, nothing that could become a second argument
                case "$2" in *[!0-9A-Za-z.:/_-]*|'') die "invalid address: $2" ;; esac
                args="$args --discover-address $2"; shift 2 ;;
            *) die "unknown discover option: $1" ;;
        esac
    done
    if [ "$env_stdin" = 1 ]; then
        tmp=$(mktemp)
        read_env_stdin "$tmp"
        set -a
        # shellcheck disable=SC1090
        . "$tmp"
        set +a
        rm -f "$tmp"
    fi
    # shellcheck disable=SC2086  # args is built from validated tokens above
    exec "$cmd" $args
}

cmd_install() {
    [ $# -eq 2 ] || die "usage: install <adapter> <instance>  (KEY=VALUE lines on stdin)"
    need_adapter "$1"; need_instance "$2"
    cmd=$(adapter_cmd "$1") || die "adapter not installed: $1"
    tmp=$(mktemp)
    read_env_stdin "$tmp"
    # options become environment variables; the core reads <PREFIX>_* as defaults and
    # --install writes them to /etc/<adapter>/<instance>.env
    set -a
    # shellcheck disable=SC1090
    . "$tmp"
    set +a
    rm -f "$tmp"
    exec "$cmd" --install --name "$2"
}

# legacy <adapter>.service → <adapter>@<name>: the adapter's own --install with the legacy env as
# environment (it carries state such as pairing keys over), then the old unit is retired
cmd_migrate() {
    [ $# -eq 2 ] || die "usage: migrate <adapter> <name>"
    need_adapter "$1"; need_instance "$2"
    legacy_unit "$1" || die "no legacy unit $1.service"
    cmd=$(adapter_cmd "$1") || die "adapter not installed: $1"
    envf=$(legacy_env_file "$1") || die "legacy env file of $1 is outside /etc/default and /etc/$1"
    [ -f "/etc/$1/$2.env" ] && die "instance $1@$2 already exists"
    if [ -f "$envf" ]; then
        set -a
        # shellcheck disable=SC1090
        . "$envf"
        set +a
    fi
    "$cmd" --install --name "$2" || die "$1 --install failed"
    systemctl disable --now "$1.service" >/dev/null 2>&1 || true
    mv "$UNIT_DIR/$1.service" "$UNIT_DIR/$1.service.migrated"
    [ -f "$envf" ] && mv "$envf" "$envf.migrated"
    systemctl daemon-reload
    printf 'migrated %s.service to %s@%s.service; old unit and env kept as .migrated\n' "$1" "$1" "$2"
}

cmd_uninstall() {
    [ $# -eq 2 ] || die "usage: uninstall <adapter> <instance>"
    need_installed "$1"; need_instance "$2"
    cmd=$(adapter_cmd "$1") || die "adapter not installed: $1"
    exec "$cmd" --uninstall --name "$2"
}

cmd_files() {
    [ $# -eq 2 ] || die "usage: files <adapter> <instance>"
    need_installed "$1"; need_instance "$2"
    tmp=$(mktemp)
    for d in "$(managed_dir "$1" "$2" etc)" "$(managed_dir "$1" "$2" state)"; do
        [ -d "$d" ] || continue
        # top level plus one level of subdirectories; no dotfiles
        find "$d" -mindepth 1 -maxdepth 2 -not -name '.*' 2>/dev/null >> "$tmp"
    done
    printf '['
    LC_ALL=C sort "$tmp" | while IFS= read -r f; do
        [ -n "$f" ] || continue
        if [ -d "$f" ]; then kind=dir; size=0; else kind=file; size=$(wc -c < "$f" | tr -d ' '); fi
        mtime=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null || echo 0)
        if [ -s "$tmp.sep" ]; then printf ','; else printf 1 > "$tmp.sep"; fi
        printf '{"path":"%s","kind":"%s","size":%s,"mtime":%s}' "$(json_escape "$f")" "$kind" "$size" "$mtime"
    done
    rm -f "$tmp" "$tmp.sep"
    printf ']\n'
}

cmd_file() {
    [ $# -eq 4 ] || die "usage: file <adapter> <instance> read|write <path>"
    need_installed "$1"; need_instance "$2"
    valid_path "$4" || die "invalid path: $4"
    inside_managed "$1" "$2" "$4" || die "path is outside /etc/$1 and /var/lib/$1/$2: $4"
    case "$3" in
        read)
            [ -f "$4" ] || die "no such file: $4"
            cat "$4" ;;
        write)
            dir=$(dirname "$4")
            [ -d "$dir" ] || die "directory does not exist: $dir"
            tmp=$(mktemp "$dir/.she.XXXXXX")
            cat > "$tmp"
            if [ -f "$4" ]; then
                cp -p "$4" "$4.bak"
                chown --reference="$4" "$tmp" 2>/dev/null || true
                chmod --reference="$4" "$tmp" 2>/dev/null || true
            else
                chown "root:$(adapter_user "$1")" "$tmp"
                chmod 0640 "$tmp"
            fi
            mv "$tmp" "$4"
            printf 'wrote %s\n' "$4" ;;
        *) die "unknown file action: $3" ;;
    esac
}

cmd_asset() {
    [ $# -eq 2 ] || die "usage: asset <adapter> <relpath>"
    need_adapter "$1"
    printf '%s' "$2" | grep -Eq '^[A-Za-z0-9_][A-Za-z0-9_./-]*$' || die "invalid asset path: $2"
    printf '%s' "$2" | grep -q '\.\.' && die "invalid asset path: $2"
    dir=$(adapter_dir "$1") || die "adapter not installed: $1"
    f="$dir/$2"
    [ -f "$f" ] || die "no such asset: $2"
    real=$(realpath "$f"); rd=$(realpath "$dir")
    case "$real" in "$rd"/*) ;; *) die "invalid asset path: $2" ;; esac
    cat "$f"
}

# she pushes a newer copy of this script through the existing sudo rule: checked before it replaces us
cmd_self_update() {
    [ $# -eq 0 ] || die "usage: self-update  (new script on stdin)"
    self=$(realpath "$0" 2>/dev/null) || self=/usr/local/bin/she-servicectl
    tmp=$(mktemp)
    cat > "$tmp"
    if ! head -n 3 "$tmp" | grep -q '^# she-servicectl'; then rm -f "$tmp"; die "self-update: not a she-servicectl script"; fi
    if ! grep -Eq '^VERSION=[0-9]+$' "$tmp"; then rm -f "$tmp"; die "self-update: no VERSION line"; fi
    if ! sh -n "$tmp" 2>/dev/null; then rm -f "$tmp"; die "self-update: syntax error in the new script"; fi
    newver=$(sed -n 's/^VERSION=\([0-9]*\)$/\1/p' "$tmp" | head -n 1)
    cp -p "$self" "$self.bak" 2>/dev/null || true
    install -m 755 -o root -g root "$tmp" "$self"
    rm -f "$tmp"
    printf 'she-servicectl updated %s -> %s at %s\n' "$VERSION" "$newver" "$self"
}

# ── removing she from this host ─────────────────────────────────────────────────
# The account she logs in as is the sudo caller (root when she connects as root).
caller_user() { printf '%s' "${SUDO_USER:-$(id -un)}"; }
key_count() { grep -cE '^[^#[:space:]]' "$1" 2>/dev/null || true; }

# drop every authorized_keys line of user $1 whose key material equals the key in file $2;
# sets REMAINING to the number of keys left
remove_key_lines() {
    home=$(getent passwd "$1" | cut -d: -f6)
    [ -n "$home" ] || die "no home directory for $1"
    auth="$home/.ssh/authorized_keys"
    keymat=$(awk 'NF >= 2 && $1 ~ /^(ssh-|ecdsa-|sk-)/ { print $2; exit }' "$2")
    [ -n "$keymat" ] || die "not a public key"
    if [ ! -f "$auth" ]; then REMAINING=0; printf 'no authorized_keys for %s, nothing to remove\n' "$1"; return 0; fi
    before=$(key_count "$auth")
    tmp=$(mktemp)
    awk -v k="$keymat" '{ keep = 1; for (i = 1; i <= NF; i++) if ($i == k) keep = 0 } keep' "$auth" > "$tmp"
    cat "$tmp" > "$auth"    # keeps owner and mode
    rm -f "$tmp"
    REMAINING=$(key_count "$auth")
    printf 'removed %s key(s) from %s, %s remaining\n' "$((before - REMAINING))" "$auth" "$REMAINING"
}

cmd_remove_key() {
    [ $# -eq 0 ] || die "usage: remove-key  (public key on stdin)"
    tmp=$(mktemp); cat > "$tmp"
    remove_key_lines "$(caller_user)" "$tmp"
    rm -f "$tmp"
}

cmd_teardown() {
    force=0
    if [ $# -eq 1 ] && [ "$1" = "--force" ]; then force=1; elif [ $# -ne 0 ]; then die "usage: teardown [--force]  (she's public key on stdin, may be empty)"; fi
    user=$(caller_user)
    self=$(realpath "$0" 2>/dev/null) || self=/usr/local/bin/she-servicectl
    REMAINING=0
    tmp=$(mktemp); cat > "$tmp"
    if grep -q . "$tmp"; then remove_key_lines "$user" "$tmp"; fi
    rm -f "$tmp"
    if [ "$user" != root ] && [ "$REMAINING" -gt 0 ] && [ $force -eq 0 ]; then
        printf 'she-servicectl: teardown: %s other key(s) still in the authorized_keys of %s — another she instance may manage this host; remove everything anyway with --force\n' "$REMAINING" "$user" >&2
        exit 3
    fi
    # sudoers: a file that allows nothing but the helper goes away, any other file loses the helper line
    for f in /etc/sudoers.d/*; do
        [ -f "$f" ] || continue
        grep -qF "$self" "$f" || continue
        if grep -vE '^[[:space:]]*(#|$)' "$f" | grep -qvF "$self"; then
            tmp=$(mktemp); grep -vF "$self" "$f" > "$tmp"; cat "$tmp" > "$f"; rm -f "$tmp"
            printf 'removed the she-servicectl line from %s\n' "$f"
        else
            rm -f "$f"; printf 'removed %s\n' "$f"
        fi
    done
    # the she-services account is the bootstrap script's; no other account is ever removed
    if [ "$user" = she-services ] && id she-services >/dev/null 2>&1; then
        userdel -r -f she-services 2>/dev/null || userdel -f she-services
        printf 'removed user she-services and its home directory\n'
    fi
    rm -f "$self.bak" "$self"
    printf 'removed %s\n' "$self"
}

# ── node ────────────────────────────────────────────────────────────────────────
# Node itself is managed with tj/n (https://github.com/tj/n). Running services keep the
# binary they were started with, so restart-all is what a version change needs afterwards.

# Download the pinned n and put it at $1. Checked the way self-update checks this script:
# interpreter line, VERSION, and a syntax check — before anything is moved into place.
fetch_n() {
    tmp=$(mktemp) || die "mktemp failed"
    if command -v curl >/dev/null 2>&1; then
        curl -fsSL -o "$tmp" "$N_URL" || { rm -f "$tmp"; die "could not download n $N_VERSION from $N_URL"; }
    elif command -v wget >/dev/null 2>&1; then
        wget -qO "$tmp" "$N_URL" || { rm -f "$tmp"; die "could not download n $N_VERSION from $N_URL"; }
    else
        rm -f "$tmp"
        die "neither curl nor wget on this host — cannot fetch n (and n needs one of them itself)"
    fi
    head -n 1 "$tmp" | grep -q '^#!.*bash' || { rm -f "$tmp"; die "downloaded file is not the n script"; }
    grep -q "^VERSION=\"\{0,1\}$N_VERSION" "$tmp" || { rm -f "$tmp"; die "downloaded n is not version $N_VERSION"; }
    bash -n "$tmp" 2>/dev/null || { rm -f "$tmp"; die "downloaded n failed the syntax check"; }
    install -m 0755 "$tmp" "$1" || { rm -f "$tmp"; die "could not install n to $1"; }
    rm -f "$tmp"
}

cmd_node() {
    [ $# -ge 1 ] || die "usage: node update [--lts|--stable|--latest|--version X.Y.Z]"
    action=$1
    shift
    [ "$action" = update ] || die "unknown node action: $action"
    # the label is handed to n as it is: stable and lts both resolve to the newest
    # long-term-support release (stable is n's older name for it), latest to the newest of all
    spec=lts
    while [ $# -gt 0 ]; do
        case "$1" in
            --lts) spec=lts ;;
            --latest) spec=latest ;;
            --stable) spec=stable ;;
            # an exact release: what a label resolves to depends on the architecture, and she
            # resolved it against the builds that exist for this host before asking
            --version)
                shift
                [ $# -gt 0 ] || die "--version needs a version"
                printf '%s' "$1" | grep -Eq '^v?[0-9]{1,3}(\.[0-9]{1,3}){0,2}$' || die "invalid version: $1"
                spec=$1
                ;;
            *) die "unknown option: $1" ;;
        esac
        shift
    done

    command -v bash >/dev/null 2>&1 || die "n is a bash script and this host has no bash (Alpine: apk add bash curl)"
    N_PREFIX=${N_PREFIX:-/usr/local}
    export N_PREFIX

    before=$(node --version 2>/dev/null || true)

    have=""
    if [ -x "$N_BIN" ]; then have=$("$N_BIN" --version 2>/dev/null || true); fi
    n_installed=false
    if [ "$have" != "$N_VERSION" ]; then
        fetch_n "$N_BIN"
        n_installed=true
    fi

    "$N_BIN" install "$spec" >&2 || die "n install $spec failed"

    hash -r 2>/dev/null || true
    installed_path="$N_PREFIX/bin/node"
    installed=$("$installed_path" --version 2>/dev/null || true)
    active_path=$(command -v node 2>/dev/null || true)
    after=$(node --version 2>/dev/null || true)
    # n put a node in N_PREFIX, but another one (distro package, nvm, an /opt/nodeXX wrapper)
    # still wins on PATH — that is the version the adapters keep running on.
    mismatch=false
    if [ -n "$installed" ] && [ "$after" != "$installed" ]; then mismatch=true; fi

    printf '{"spec":"%s","before":%s,"after":%s,"installed":%s,"activePath":%s,"installedPath":"%s","mismatch":%s,"n":"%s","nInstalled":%s}\n' \
        "$spec" \
        "$(json_str "$before")" "$(json_str "$after")" "$(json_str "$installed")" "$(json_str "$active_path")" \
        "$(json_escape "$installed_path")" "$mismatch" "$N_VERSION" "$n_installed"
}

# Restart every instance that is currently running — units that are stopped or disabled
# on purpose stay that way.
cmd_restart_all() {
    [ $# -eq 0 ] || die "usage: restart-all"
    first=1
    printf '{"restarted":['
    for unit in "$UNIT_DIR"/*@.service; do
        [ -f "$unit" ] || continue
        a=$(basename "$unit" @.service)
        valid_adapter "$a" || continue
        installed_adapter "$a" || continue
        for envf in "/etc/$a"/*.env; do
            [ -f "$envf" ] || continue
            i=$(basename "$envf" .env)
            valid_instance "$i" || continue
            systemctl is-active --quiet "$a@$i.service" || continue
            if err=$(systemctl restart "$a@$i.service" 2>&1); then ok=true; else ok=false; fi
            [ $first -eq 1 ] || printf ','
            first=0
            printf '{"adapter":"%s","instance":"%s","ok":%s,"error":%s}' \
                "$a" "$i" "$ok" \
                "$([ "$ok" = true ] && printf 'null' || printf '"%s"' "$(json_escape "$err")")"
        done
    done
    for unit in "$UNIT_DIR"/*.service; do
        [ -f "$unit" ] || continue
        a=$(basename "$unit" .service)
        case "$a" in *@) continue ;; esac
        valid_adapter "$a" || continue
        legacy_unit "$a" || continue
        systemctl is-active --quiet "$a.service" || continue
        if err=$(systemctl restart "$a.service" 2>&1); then ok=true; else ok=false; fi
        [ $first -eq 1 ] || printf ','
        first=0
        printf '{"adapter":"%s","instance":"-","ok":%s,"error":%s}' \
            "$a" "$ok" \
            "$([ "$ok" = true ] && printf 'null' || printf '"%s"' "$(json_escape "$err")")"
    done
    printf ']}\n'
}

cmd_npm() {
    [ $# -ge 2 ] || die "usage: npm <adapter> version|origin|install|update|uninstall [--purge]"
    need_adapter "$1"
    case "$2" in
        uninstall)
            # the package and what it left behind; instances must be gone (she removes them first)
            if [ -n "$(ls "/etc/$1"/*.env 2>/dev/null)" ]; then die "instances of $1 still exist — uninstall them first"; fi
            npm=$(adapter_npm "$1")
            node=$(adapter_node "$1")
            PATH="$(dirname "$node"):$PATH" "$npm" uninstall -g "$1" --no-audit --no-fund 2>&1 || true
            dir=$(adapter_dir "$1" 2>/dev/null || true)
            if [ -n "$dir" ] && [ -d "$dir" ]; then case "$dir" in */node_modules/"$1") rm -rf "$dir"; printf 'removed %s\n' "$dir" ;; esac; fi
            [ -L "/usr/local/bin/$1" ] && [ ! -e "/usr/local/bin/$1" ] && rm -f "/usr/local/bin/$1"
            if [ -f "$UNIT_DIR/$1@.service" ]; then rm -f "$UNIT_DIR/$1@.service"; systemctl daemon-reload; printf 'removed %s\n' "$UNIT_DIR/$1@.service"; fi
            if [ "${3:-}" = "--purge" ]; then
                for d in "/etc/$1" "/var/lib/$1"; do [ -d "$d" ] && rm -rf "$d" && printf 'removed %s\n' "$d"; done
            else
                for d in "/etc/$1" "/var/lib/$1"; do [ -d "$d" ] && rmdir "$d" 2>/dev/null && printf 'removed empty %s\n' "$d"; done
            fi
            printf '%s uninstalled\n' "$1" ;;
        version) adapter_version "$1"; printf '\n' ;;
        origin) adapter_origin "$1"; printf '\n' ;;
        install|update)
            npm=$(adapter_npm "$1")
            node=$(adapter_node "$1")
            PATH="$(dirname "$node"):$PATH" exec "$npm" install -g "$1@latest" --no-audit --no-fund ;;
        *) die "unknown npm action: $2" ;;
    esac
}

# ── dispatch ────────────────────────────────────────────────────────────────────

[ $# -ge 1 ] || die "usage: she-servicectl <command> …"
c=$1; shift
case "$c" in
    version) cmd_version "$@" ;;
    list) cmd_list "$@" ;;
    unit) cmd_unit "$@" ;;
    logs) cmd_logs "$@" ;;
    env) cmd_env "$@" ;;
    broker-env) cmd_broker_env "$@" ;;
    schema) cmd_schema "$@" ;;
    discover) cmd_discover "$@" ;;
    install) cmd_install "$@" ;;
    uninstall) cmd_uninstall "$@" ;;
    npm) cmd_npm "$@" ;;
    node) cmd_node "$@" ;;
    restart-all) cmd_restart_all "$@" ;;
    files) cmd_files "$@" ;;
    file) cmd_file "$@" ;;
    asset) cmd_asset "$@" ;;
    self-update) cmd_self_update "$@" ;;
    migrate) cmd_migrate "$@" ;;
    remove-key) cmd_remove_key "$@" ;;
    teardown) cmd_teardown "$@" ;;
    *) die "unknown command: $c" ;;
esac
