#!/usr/bin/env bash
set -euo pipefail
# POS: communication telegram-listener — Telegram bot listener: map /command → bash and <prefix> → app, run them on chat messages
# POS_FLAGS: --enable --disable --status --sync-commands --run
# POS_SUBCMDS: prefix

CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}"
CONFIG_FILE="$CONFIG_DIR/telegram.env"
MAP_FILE="$CONFIG_DIR/telegram_commands.env"
PREFIX_FILE="$CONFIG_DIR/telegram_prefixes.env"
# Persisted getUpdates offset (survives restarts so unconfirmed updates are
# never re-delivered in a burst after a crash/restart).
STATE_FILE="${LISTENER_STATE_FILE:-$CONFIG_DIR/telegram-listener.state}"
API="https://api.telegram.org"
SERVICE="pos-telegram-listener.service"
USER_SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"

# Shared config loader (canonical KEY=VALUE parser, env-wins precedence)
source "$(dirname "$0")/../lib/config-ui.sh" 2>/dev/null || source "$(dirname "$0")/config-ui.sh"

# System prompt for the "<prefix> " AI bridge (default prefix: "ai"): replies
# are posted straight into the chat, so ask for concise, emoji-friendly
# Telegram-style answers. The trigger word is configurable via
# TELEGRAM_AI_PREFIX in telegram.env ('pos config telegram', default 'ai');
# the text-prefix map checked before it can override any word.
AI_SYSTEM="You are a friendly assistant chatting in a Telegram chat. Keep replies concise, use emojis and light formatting to make them lively, and never claim to send messages yourself."

err()  { echo "ERROR: $*" >&2; exit 1; }
log()  { echo "[+] $*"; }
warn() { echo "[!] $*" >&2; }

usage() {
    cat <<EOF
Usage: pos communication telegram listener [command]

Telegram bot listener: map /command → bash commands and run them from chat.

Commands:
  (none)       Interactive editor for the /command → bash map
  --enable     Install + start the systemd user service (autostarts on login)
  --disable    Stop + disable + remove the service
  --status     Show service state (single-instance lock) and the command map
  --sync-commands
               Push the mapped /commands to the bot's "/" menu (setMyCommands)
  --run        Run the polling loop in the foreground (used by the service).
               Single instance: only one --run may poll the bot token at a
               time — a second --run exits immediately with an error.
  prefix [word [command...]]
               Manage the text-prefix map (telegram_prefixes.env): any
               non-command message '<prefix> <text>' runs the mapped command
               with <text> as ONE argument. Bare: list; <word>: show one;
               <word> <command...>: map (e.g. 'prefix opencode opencode' →
               "opencode check cpu" runs 'opencode "check cpu"');
               -r <word>: remove. Built-in Gemini bridge word (default 'ai')
               is set via 'pos config telegram' (TELEGRAM_AI_PREFIX).

Config:  $CONFIG_FILE  (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID,
         TELEGRAM_OWNER_ID, TELEGRAM_AI_PREFIX — edit with 'pos
         config telegram')
Map:     $MAP_FILE — '/cmd=bash command' per line (optional
         '/cmd::short description=bash command' shown in the bot menu)
Prefix map: $PREFIX_FILE — '<word>=command' per line: a non-command
         message starting with '<word> <text>' runs the command with <text>
         appended as ONE quoted argument. First match wins (file order),
         case-insensitive; a mapped word shadows the built-in Gemini 'ai'
         bridge. Routing order: text-prefix map → AI bridge → /command map →
         'Unknown command'. Manage it with the 'prefix' verb.

Prefix a map value with '@quiet ' to run the command without replying —
for commands that already send their own notification, so you don't get it
twice (e.g. '/backup=@quiet pos system backup --send').

To reply with a command's stdout (e.g. the health dashboard), just map it
without '@quiet' and the listener forwards the output:
  '/status=pos system health'

The listener only reacts to messages sent to the owner chat
(TELEGRAM_CHAT_ID) BY your account (TELEGRAM_OWNER_ID) — both must match,
so an impersonator or a forwarded message can't trigger commands. If
TELEGRAM_OWNER_ID is unset the daemon starts but refuses to run any chat
command (fail-closed); set it with 'pos config telegram'. Commands run
as your user, so 'sudo' inside them needs a NOPASSWD rule. The interactive
editor runs 'bash -n' to syntax-check commands before saving.

The command list is pushed to the bot's "/" menu (setMyCommands) after every
map edit, on --enable, and at daemon start; force it anytime with
--sync-commands. Telegram only accepts lowercase [a-z0-9_] names — any other
command is skipped from the menu but still resolves when typed.

Examples:
  pos communication telegram listener
  pos communication telegram listener --enable
  pos communication telegram listener --status
EOF
    exit 0
}

# ── telegram.env (same pattern as pos-communication-telegram) ────
load_config() {
    load_env_file "$CONFIG_FILE"
}

# ── getUpdates offset persistence ────────────────────────────────
# load_offset — read the last confirmed update offset from STATE_FILE.
# Returns 0 when the file is missing/invalid (fresh start).
load_offset() {
    local val
    [ -f "$STATE_FILE" ] || { printf '0'; return 0; }
    val="$(cat "$STATE_FILE" 2>/dev/null || true)"
    case "$val" in
        ''|*[!0-9]*) printf '0' ;;
        *) printf '%s' "$val" ;;
    esac
}

# save_offset — atomically persist the last processed update offset.
save_offset() {
    local val="$1"
    mkdir -p "$(dirname "$STATE_FILE")"
    local tmp
    tmp="$(mktemp)"
    printf '%s\n' "$val" > "$tmp"
    chmod 600 "$tmp"
    mv "$tmp" "$STATE_FILE"
    chmod 600 "$STATE_FILE"
}

# ── command map (MAP_FILE) ──────────────────────────────────────
# Lines: /cmd=bash command, or /cmd::description=bash command. Keys keep the
# leading slash; read via awk so values may contain '='. Entries are emitted
# with a \x1f (unit separator) delimiter so bash commands may contain pipes.
# The map is re-read per message — edits apply without restarting the listener.

map_entries() {
    [ -f "$MAP_FILE" ] || return 0
    grep -E '^/[A-Za-z0-9_.-]+(::.*)?=' "$MAP_FILE" | while IFS= read -r line; do
        local left="${line%%=*}" cmd desc
        if [[ "$left" == *::* ]]; then
            cmd="${left%%::*}"
            desc="${left#*::}"
        else
            cmd="$left"
            desc=""
        fi
        printf '%s\x1f%s\x1f%s\n' "$cmd" "$desc" "${line#*=}"
    done
}

map_has() {
    [ -f "$MAP_FILE" ] || return 1
    awk -F= -v k="$1" '$1==k || index($1, k "::")==1 {found=1} END{exit !found}' "$MAP_FILE"
}

map_get() {
    [ -f "$MAP_FILE" ] || return 0
    awk -F= -v k="$1" '$1==k || index($1, k "::")==1 {sub(/^[^=]*=/,""); print}' "$MAP_FILE"
}

map_set() {
    local cmd="$1" value="$2" desc="${3:-}"
    local key="$cmd"
    mkdir -p "$CONFIG_DIR"
    touch "$MAP_FILE"
    chmod 600 "$MAP_FILE"
    local tmp
    tmp="$(mktemp)"
    [ -n "$desc" ] && key="${cmd}::${desc}"
    awk -v k="$cmd" 'index($0, k "=") != 1 && index($0, k "::") != 1 { print }' "$MAP_FILE" > "$tmp"
    printf '%s=%s\n' "$key" "$value" >> "$tmp"
    mv "$tmp" "$MAP_FILE"
    chmod 600 "$MAP_FILE"
}

map_del() {
    [ -f "$MAP_FILE" ] || return 0
    local cmd="$1" tmp
    tmp="$(mktemp)"
    awk -v k="$cmd" 'index($0, k "=") != 1 && index($0, k "::") != 1 { print }' "$MAP_FILE" > "$tmp"
    mv "$tmp" "$MAP_FILE"
    chmod 600 "$MAP_FILE"
}

MAP_CMDS=(); MAP_VALS=(); MAP_DESCS=(); MAP_N=0
load_map() {
    MAP_CMDS=(); MAP_VALS=(); MAP_DESCS=(); MAP_N=0
    [ -f "$MAP_FILE" ] || return 0
    local i=0 cmd desc value
    while IFS=$'\x1f' read -r cmd desc value; do
        [ -n "$cmd" ] || continue
        i=$((i + 1))
        MAP_CMDS[$i]="$cmd"; MAP_DESCS[$i]="$desc"; MAP_VALS[$i]="$value"
    done <<< "$(map_entries)"
    MAP_N="$i"
}

map_cmds_list() {
    load_map
    local out="" i
    for ((i=1; i<=MAP_N; i++)); do
        [ -n "$out" ] && out+=", "
        out+="${MAP_CMDS[$i]}"
    done
    printf '%s' "${out:-none}"
}

# ── Bot command menu sync (setMyCommands) ──────────────────────
# Push the mapped /commands to the bot's "/" menu so they show in the
# Telegram UI, not just resolve when typed. Runs after map edits, on
# --enable, and at daemon start; force it with --sync-commands.

description_for() {
    local value="$1" desc="${2:-}"
    if [ -n "$desc" ]; then
        printf '%s' "$desc"
        return
    fi
    value="$(strip_quiet "$value")"
    printf '%s' "${value:0:40}"
}

build_commands_json() {
    load_map
    local i name desc
    for ((i=1; i<=MAP_N; i++)); do
        name="${MAP_CMDS[$i]#/}"
        if ! [[ "$name" =~ ^[a-z][a-z0-9_]{0,31}$ ]]; then
            warn "skip '$name' from bot menu — Telegram commands are lowercase [a-z0-9_], 1-32 chars"
            continue
        fi
        desc="$(description_for "${MAP_VALS[$i]}" "${MAP_DESCS[$i]}")"
        printf '%s\t%s\n' "$name" "$desc"
    done | jq -Rr 'split("\t") | {command: .[0], description: .[1]}' | jq -sc '.'
}

sync_bot_commands() {
    load_config
    [ -n "${TELEGRAM_BOT_TOKEN:-}" ] || { warn "no bot token — run 'pos config telegram' first"; return 1; }
    command -v jq &>/dev/null || { warn "jq not found — cannot sync bot commands"; return 1; }
    local json n
    json="$(build_commands_json)"
    n="$(printf '%s' "$json" | jq 'length')"
    if curl -fsS -m 30 -X POST "${API}/bot${TELEGRAM_BOT_TOKEN}/setMyCommands" \
            --data-urlencode "commands=${json}" >/dev/null 2>&1; then
        log "bot command menu updated ($n commands)"
        return 0
    fi
    warn "setMyCommands failed"
    return 1
}

check_syntax() {
    bash -n -c "$(strip_quiet "$1")" 2>&1
}

# ── interactive editor ──────────────────────────────────────────
ui_pick() {
    load_map
    if [ "$MAP_N" -eq 0 ]; then
        warn "no commands mapped yet — add one first"
        return 1
    fi
    echo >&2
    local i
    for ((i=1; i<=MAP_N; i++)); do
        printf '  %2d) %-16s -> %s%s\n' "$i" "${MAP_CMDS[$i]}" "${MAP_VALS[$i]}" "${MAP_DESCS[$i]:+ (${MAP_DESCS[$i]})}" >&2
    done
    local idx
    read -rp "Entry number: " idx
    if ! [[ "$idx" =~ ^[0-9]+$ ]] || (( idx < 1 || idx > MAP_N )); then
        warn "invalid number '$idx'"
        return 1
    fi
    echo "$idx"
}

# Map entries may be prefixed with '@quiet ' to run the command without
# replying — for commands that deliver their own notification (e.g. backup's
# --send). The marker is stripped before running and before the editor's
# bash -n syntax check.
QUIET_PREFIX="@quiet"

strip_quiet() {
    local value="$1"
    if [ "${value#"$QUIET_PREFIX "}" != "$value" ]; then
        printf '%s' "${value#"$QUIET_PREFIX "}"
    else
        printf '%s' "$value"
    fi
}

# ── text-prefix map (PREFIX_FILE) ───────────────────────────────
# Lines: <word>=command. Unlike the /command map (exact match on the whole
# message), a matching <word> at the START of a non-command message routes
# the REST of the message to the mapped command as ONE quoted argument:
#   'opencode=opencode' + message "opencode check cpu" → 'opencode "check cpu"'
# The map is re-read per message (edits apply without restarting), matching
# is case-insensitive, the FIRST matching line wins (file order), a bare
# <word> with no trailing space does NOT match, and a mapped word shadows the
# built-in Gemini bridge in handle_message.

prefix_map_find() {
    [ -f "$PREFIX_FILE" ] || return 1
    local text="$1" line word cmd rem
    shopt -s nocasematch
    while IFS= read -r line; do
        case "$line" in \#*|'') continue ;; esac
        word="${line%%=*}"
        cmd="${line#*=}"
        word="${word# }"
        cmd="${cmd# }"
        [[ -n "$word" && -n "$cmd" ]] || continue
        if [[ "$text" =~ ^"$word"[[:space:]](.*)$ ]]; then
            rem="${BASH_REMATCH[1]}"
            [ -n "$rem" ] || continue
            shopt -u nocasematch
            printf '%s\x1f%s\n' "$cmd" "$rem"
            return 0
        fi
    done < "$PREFIX_FILE"
    shopt -u nocasematch
    return 1
}

prefix_map_set() {
    local word="$1" cmd="$2"
    if ! [[ "$word" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then
        err "invalid prefix '$word' — use one word of letters, digits, '-' or '_' (no spaces)"
    fi
    local errs
    errs="$(check_syntax "$cmd" 2>&1)" || err "invalid command for '$word': $errs"
    mkdir -p "$CONFIG_DIR"
    touch "$PREFIX_FILE"
    chmod 600 "$PREFIX_FILE"
    local tmp
    tmp="$(mktemp)"
    awk -v k="$word" 'index($0, k "=") != 1 { print }' "$PREFIX_FILE" > "$tmp"
    printf '%s=%s\n' "$word" "$cmd" >> "$tmp"
    mv "$tmp" "$PREFIX_FILE"
    chmod 600 "$PREFIX_FILE"
}

prefix_map_del() {
    [ -f "$PREFIX_FILE" ] || return 1
    local tmp
    tmp="$(mktemp)"
    awk -v k="$1" 'index($0, k "=") != 1 { print }' "$PREFIX_FILE" > "$tmp"
    if cmp -s "$tmp" "$PREFIX_FILE"; then
        rm -f "$tmp"
        return 1
    fi
    mv "$tmp" "$PREFIX_FILE"
    chmod 600 "$PREFIX_FILE"
    return 0
}

prefix_map_show() {
    local word="$1" line key
    [ -f "$PREFIX_FILE" ] || return 1
    while IFS= read -r line; do
        case "$line" in \#*|'') continue ;; esac
        key="${line%%=*}"
        if [ "$key" = "$word" ]; then
            printf '%s -> %s\n' "$key" "${line#*=}"
            return 0
        fi
    done < "$PREFIX_FILE"
    return 1
}

# ── async command execution ─────────────────────────────────────
# Commands run in the background so the listener never blocks.  stdin is
# /dev/null (prevents interactive hangs — FFmpeg reading 'q', scripts
# waiting for prompts); stdout+stderr go to a temp file; output is collected
# and replied asynchronously from the main loop.
#
# PID → metadata arrays (populated by run_and_reply, drained by reap_commands)
declare -A _CMD_OUT _CMD_MSG _CMD_QUIET
#
# No SIGCHLD reaper: `wait -n`/`wait -n -p` inside a CHLD trap is unreliable
# on bash 5.2 (it reports "no children" even when processes have exited), so
# a trap-based reaper silently never fires. reap_commands below polls with
# kill -0 and reaps via `wait "$pid" || rc=$?` — simple, non-blocking, and
# exit-code-correct (see the crash this design replaces).

# run_and_reply <cmdline> <msg_id> [timeout] [quiet]
# Starts the command in the background and returns immediately.  The main
# loop calls reap_commands after each getUpdates cycle to collect output
# and send replies.
run_and_reply() {
    local cmdline="$1" msg_id="$2" tmo="${3:-120}" quiet="${4:-0}"
    local out_file
    out_file="$(mktemp /tmp/pos-cmd.XXXXXX)"
    # stdin=/dev/null: prevents interactive hangs (FFmpeg 'q', read prompts).
    # The child inherits nothing from the listener's own stdin.
    timeout "$tmo" bash -c "$cmdline" </dev/null >"$out_file" 2>&1 &
    local pid=$!
    _CMD_OUT[$pid]="$out_file"
    _CMD_MSG[$pid]="$msg_id"
    _CMD_QUIET[$pid]="$quiet"
}

# reap_commands — called from the main loop after each getUpdates cycle.
# Checks every tracked PID with kill -0 (non-blocking); when a process has
# exited, reads its output file and sends the reply.  Never blocks the loop.
reap_commands() {
    local pid
    for pid in "${!_CMD_OUT[@]}"; do
        # Non-blocking: has the process exited?
        if ! kill -0 "$pid" 2>/dev/null; then
            # Retrieve exit code. `wait "$pid"` returns immediately since the
            # process has exited. IMPORTANT: a bare `wait "$pid"` under
            # `set -e` would abort the daemon on non-zero exits (the crash
            # this code replaces), so the status is captured via `|| rc=$?`.
            local rc=0
            wait "$pid" 2>/dev/null || rc=$?
            local out_file="${_CMD_OUT[$pid]}"
            local msg_id="${_CMD_MSG[$pid]}"
            local quiet="${_CMD_QUIET[$pid]}"
            local output=""
            [ -s "$out_file" ] && output="$(cat "$out_file" 2>/dev/null)"
            rm -f "$out_file"
            if [ "$quiet" -ne 1 ]; then
                [ -n "$output" ] || output="OK"
                if [ "$rc" -ne 0 ]; then
                    reply "$(printf 'exit %s\n%s' "$rc" "$output")" "$msg_id" "$rc"
                else
                    reply "$output" "$msg_id"
                fi
            fi
            unset _CMD_OUT[$pid] _CMD_MSG[$pid] _CMD_QUIET[$pid]
        fi
    done
}

ui_run_command() {
    local value="$1" output rc
    value="$(strip_quiet "$value")"
    echo
    echo "--- running: $value"
    if output="$(timeout 60 bash -c "$value" 2>&1)"; then
        rc=0
    else
        rc=$?
    fi
    printf '%s\n' "$output" | head -c 3800
    [ -z "$output" ] || echo
    echo "--- exit $rc"
}

ui_add() {
    local cmd value desc out
    read -rp "/command name (e.g. /status): " cmd
    [ -n "$cmd" ] || { warn "empty command name"; return; }
    case "$cmd" in
        /[A-Za-z0-9_.-]*) ;;
        *) warn "command must start with '/' and use [A-Za-z0-9_.-]: $cmd"; return ;;
    esac
    read -rp "bash command: " value
    [ -n "$value" ] || { warn "empty bash command"; return; }
    read -rp "description (optional, shown in the bot menu): " desc
    if out="$(check_syntax "$value")"; then
        map_set "$cmd" "$value" "$desc"
        sync_bot_commands || true
        log "saved $cmd -> $value"
    else
        warn "syntax error — not saved:"
        printf '%s\n' "$out" | sed 's/^/    /'
        return
    fi
    local yn
    read -rp "Test-run it now? [y/N] " yn
    case "$yn" in
        y|Y) ui_run_command "$value" ;;
    esac
}

ui_edit() {
    local idx cmd value desc out
    idx="$(ui_pick)" || return
    cmd="${MAP_CMDS[$idx]}"
    echo "Editing: $cmd -> ${MAP_VALS[$idx]}"
    read -rp "bash command: " value
    [ -n "$value" ] || { warn "empty bash command"; return; }
    read -rp "description (current: ${MAP_DESCS[$idx]:-none}): " desc
    if out="$(check_syntax "$value")"; then
        map_set "$cmd" "$value" "$desc"
        sync_bot_commands || true
        log "updated $cmd -> $value"
    else
        warn "syntax error — not saved:"
        printf '%s\n' "$out" | sed 's/^/    /'
    fi
}

ui_remove() {
    local idx cmd yn
    idx="$(ui_pick)" || return
    cmd="${MAP_CMDS[$idx]}"
    read -rp "Remove '$cmd'? [y/N] " yn
    case "$yn" in
        y|Y) map_del "$cmd"; sync_bot_commands || true; log "removed $cmd" ;;
        *) warn "canceled" ;;
    esac
}

ui_test() {
    local idx
    idx="$(ui_pick)" || return
    ui_run_command "${MAP_VALS[$idx]}"
}

ui() {
    local choice
    while true; do
        echo
        echo "Telegram listener — /command -> bash map"
        echo "-----------------------------------------"
        load_map
        if [ "$MAP_N" -eq 0 ]; then
            echo "  (no commands mapped yet)"
        else
            local i
            for ((i=1; i<=MAP_N; i++)); do
                printf '  %2d) %-16s -> %s%s\n' "$i" "${MAP_CMDS[$i]}" "${MAP_VALS[$i]}" "${MAP_DESCS[$i]:+ (${MAP_DESCS[$i]})}"
            done
        fi
        echo
        read -rp "Menu: [a]dd  [e]dit  [r]emove  [t]est  [q]uit > " choice
        case "$choice" in
            a|A|add) ui_add ;;
            e|E|edit) ui_edit ;;
            r|R|remove) ui_remove ;;
            t|T|test) ui_test ;;
            q|Q|quit|exit) echo; log "bye"; return 0 ;;
            *) warn "unknown choice '$choice'" ;;
        esac
    done
}

# ── systemd user service ────────────────────────────────────────
enable_service() {
    command -v systemctl &>/dev/null || err "systemctl not found — cannot create the listener service"
    mkdir -p "$USER_SYSTEMD_DIR"

    local runner
    if [ -x /usr/local/bin/pos-communication-telegram-listener ]; then
        runner=/usr/local/bin/pos-communication-telegram-listener
    else
        runner="$(cd "$(dirname "$0")/.." && pwd)/bin/pos-communication-telegram-listener"
        warn "using repo path $runner — re-run 'install.sh' so the service survives a deleted repo"
    fi

    cat >"$USER_SYSTEMD_DIR/$SERVICE" <<EOF
[Unit]
Description=pos Telegram listener (/command to bash)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=$runner --run
Restart=always
RestartSec=5
KillMode=control-group
TimeoutStopSec=5s
# Do NOT pin Environment=HOME here — the systemd user manager already sets the
# correct HOME for the user; pinning a stale enable-time value made the daemon
# read ~/.config/... from the wrong home (menu shows commands, daemon says unknown).

[Install]
WantedBy=default.target
EOF
    chmod 644 "$USER_SYSTEMD_DIR/$SERVICE"

    systemctl --user daemon-reload
    systemctl --user enable --now "$SERVICE"
    log "listener service enabled: $SERVICE"
    sync_bot_commands || true
    warn "commands run as $(id -un) — 'sudo' inside them needs a NOPASSWD rule"
    if command -v loginctl >/dev/null 2>&1; then
        if ! loginctl show-user "$(id -un)" 2>/dev/null | grep -q '^Linger=yes'; then
            warn "enable linger so the listener survives logout: sudo loginctl enable-linger $(id -un)"
        fi
    fi
}

disable_service() {
    if [ ! -f "$USER_SYSTEMD_DIR/$SERVICE" ]; then
        warn "no listener service installed ($SERVICE)"
        exit 0
    fi
    systemctl --user disable --now "$SERVICE" 2>/dev/null || true
    rm -f "$USER_SYSTEMD_DIR/$SERVICE"
    systemctl --user daemon-reload
    log "listener service disabled"
}

status() {
    if lock_held; then
        echo "listener:  running (single instance lock held)"
    else
        echo "listener:  not running"
    fi
    if systemctl --user is-enabled "$SERVICE" >/dev/null 2>&1; then
        echo "autostart: enabled (starts on login)"
    else
        echo "autostart: disabled"
    fi
    echo "config:    $CONFIG_FILE"
    echo "map file:  $MAP_FILE"
    echo "prefix map: $PREFIX_FILE"
    load_config
    echo "ai prefix: ${TELEGRAM_AI_PREFIX:-ai}"
    load_map
    echo "commands:  $MAP_N mapped"
    local i
    for ((i=1; i<=MAP_N; i++)); do
        printf '  %-16s -> %s%s\n' "${MAP_CMDS[$i]}" "${MAP_VALS[$i]}" "${MAP_DESCS[$i]:+ (${MAP_DESCS[$i]})}"
    done
    if [ -f "$PREFIX_FILE" ] && [ -s "$PREFIX_FILE" ]; then
        local pline pword pcmd pn=0
        echo "prefixes:  ('<prefix> <text>' routes to the mapped app)"
        while IFS= read -r pline; do
            case "$pline" in \#*|'') continue ;; esac
            pword="${pline%%=*}" pcmd="${pline#*=}"
            pn=$((pn + 1))
            printf '  %-16s -> %s\n' "$pword" "$pcmd"
        done < "$PREFIX_FILE"
        [ "$pn" -gt 0 ] || echo "  (none)"
    else
        echo "prefixes:  (none)"
    fi
}

# ── text-prefix map + built-in Gemini bridge word (prefix verb) ──
# 'prefix' manages PREFIX_FILE (word → command). The built-in Gemini bridge
# word (TELEGRAM_AI_PREFIX, default 'ai') is set via 'pos config telegram';
# it is only reached when no text-prefix entry matches first.
prefix_cmd() {
    local arg="${1:-}" rest="${*:2}"
    if [ -z "$arg" ]; then
        load_config
        echo "Text-prefix map ($PREFIX_FILE): messages like '<prefix> <text>' run the"
        echo "mapped command with <text> passed as ONE argument. First match wins"
        echo "(file order), case-insensitive; a mapped word shadows the built-in"
        echo "Gemini bridge. A bare <prefix> with no trailing space does not match."
        echo
        if [ -f "$PREFIX_FILE" ] && [ -s "$PREFIX_FILE" ]; then
            local line word cmd
            while IFS= read -r line; do
                case "$line" in \#*|'') continue ;; esac
                word="${line%%=*}" cmd="${line#*=}"
                printf '  %-16s -> %s\n' "$word" "$cmd"
            done < "$PREFIX_FILE"
        else
            echo "  (none)"
        fi
        echo
        echo "Built-in Gemini bridge word: ${TELEGRAM_AI_PREFIX:-ai}  (set via 'pos config telegram')"
        echo
        echo "Usage:"
        echo "  prefix                          list this map + the Gemini bridge word"
        echo "  prefix <word>                   show one mapping"
        echo "  prefix <word> <command...>      map <word> to a command"
        echo "  prefix -r <word>                remove a mapping"
        echo "Examples:"
        echo "  pos communication telegram listener prefix opencode opencode"
        echo "  # then sending 'opencode check cpu' runs: opencode \"check cpu\""
        return 0
    fi
    case "$arg" in
        -r|--remove)
            [ -n "$rest" ] || err "usage: prefix -r <word>"
            if prefix_map_del "$rest"; then
                log "removed prefix mapping '$rest'"
            else
                warn "no prefix mapping for '$rest'"
            fi
            return 0 ;;
    esac
    if [ -z "$rest" ]; then
        prefix_map_show "$arg" && return 0
        warn "no prefix mapping for '$arg'"
        echo "Map one with: prefix <word> <command...>   (e.g. prefix opencode opencode)"
        echo "The built-in Gemini bridge word is set via 'pos config telegram' (TELEGRAM_AI_PREFIX)."
        return 1
    fi
    prefix_map_set "$arg" "$rest"
    log "prefix '$arg' -> '$rest' — sending '<$arg> <text>' runs: $rest \"<text>\" (takes effect immediately, no restart)"
}

# Current AI-bridge trigger word. Like the command map, re-read per message so
# 'prefix <word>' edits apply without restarting the daemon. Precedence:
# telegram.env > env var from load_config (--run) > default 'ai'.
ai_bridge_prefix() {
    local v=""
    [ -f "$CONFIG_FILE" ] && v="$(grep -E '^TELEGRAM_AI_PREFIX=' "$CONFIG_FILE" | tail -1 | sed 's/^[^=]*=//; s/^["'\'']//; s/["'\'']$//')" || true
    [ -n "$v" ] || v="${TELEGRAM_AI_PREFIX:-ai}"
    printf '%s' "$v"
}

# ── polling daemon ──────────────────────────────────────────────
reply() {
    local text="$1" msg_id="$2" rc="${3:-}"
    text="${text:0:3800}"
    local args=(--data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" --data-urlencode "text=${text}")
    [ -n "$msg_id" ] && args+=(--data-urlencode "reply_to_message_id=${msg_id}")
    curl -fsS -m 60 -X POST "${API}/bot${TELEGRAM_BOT_TOKEN}/sendMessage" "${args[@]}" >/dev/null 2>&1 \
        || warn "reply failed (rc ${rc:-?})"
}

# Strip common markdown so AI output reads cleanly in a plain-text
# Telegram message (no parse_mode is used).
strip_markdown() {
    local t="$1"
    t="$(printf '%s' "$t" | sed -E \
        -e 's/!\[[^]]*\]\([^)]*\)//g' \
        -e 's/\[([^]]*)\]\([^)]*\)/\1/g' \
        -e 's/\*\*([^*]*)\*\*/\1/g' \
        -e 's/\*([^*]*)\*/\1/g' \
        -e 's/__([^_]*)__/\1/g' \
        -e 's/`([^`]*)`/\1/g' \
        -e 's/^[[:space:]]*#{1,6}[[:space:]]+//' \
        -e 's/^[[:space:]]*>[[:space:]]?//' \
        -e 's/^[[:space:]]*([-*+]|[0-9]+\.)[[:space:]]+/• /')"
    t="$(printf '%s' "$t" | sed -E '/^[[:space:]]*([-*_][[:space:]]*){3,}[[:space:]]*$/d')"
    printf '%s' "$t"
}

# Detect a bare URL in message text. Extracts the first http(s) URL.
# Returns 0 + prints the URL on success, 1 if no URL found.
url_detect() {
    local text="$1"
    local url=""
    if [[ "$text" =~ (https?://[^[:space:]]+) ]]; then
        url="${BASH_REMATCH[1]}"
        url="${url%%[,.\)!?:;]}"
        url="${url%%\>*}"
        [ -n "$url" ] || return 1
        printf '%s' "$url"
        return 0
    fi
    return 1
}

handle_message() {
    local text="$1" msg_id="$2" reply_text="${3:-}" value quiet=0
    case "$text" in
        /help|/start)
            reply "Mapped commands: $(map_cmds_list)" "$msg_id"
            return ;;
    esac
    # Text-prefix bridge: '<prefix> <text>' runs the mapped command with
    # <text> passed as ONE quoted argument (telegram_prefixes.env), e.g.
    # 'opencode=opencode' → sending "opencode check cpu" runs
    # 'opencode "check cpu"'. Re-read per message, first match wins (file
    # order), case-insensitive; a mapped word shadows the built-in Gemini
    # bridge below. A bare <prefix> with no trailing space does not match.
    local pv cmd t qtext
    if pv="$(prefix_map_find "$text")"; then
        cmd="${pv%%$'\x1f'*}"
        t="${pv#*$'\x1f'}"
        if [ "${cmd#"$QUIET_PREFIX "}" != "$cmd" ]; then
            quiet=1
            cmd="${cmd#"$QUIET_PREFIX "}"
        fi
        qtext="$(printf '%q' "$t")"
        log "prefix: $text"
        run_and_reply "$cmd $qtext" "$msg_id" 120 "$quiet"
        return
    fi
    # URL detect: bare HTTP(S) URLs → pos media grab
    local grab_url
    if grab_url="$(url_detect "$text")"; then
        log "grab: $grab_url"
        run_and_reply "pos media grab --best \"$grab_url\"" "$msg_id" 600
        return
    fi
    # AI bridge: non-command text starting with "<prefix> " (default "ai",
    # case-insensitive, configurable via TELEGRAM_AI_PREFIX in 'pos config
    # telegram') is forwarded to Gemini; the model's answer is replied
    # verbatim. Each chat gets its own persistent memory session
    # ("telegram-<chat_id>"); the exact prompt "<prefix> /reset" clears it.
    # Future non-command intents (e.g. reminders) slot in as more case arms
    # here.
    local prefix
    prefix="$(ai_bridge_prefix)"
    shopt -s nocasematch
    if [[ "$text" != /* && "$text" =~ ^"$prefix"[[:space:]](.*)$ ]]; then
        shopt -u nocasematch
        local prompt="${BASH_REMATCH[1]}" answer session
        [ -n "$prompt" ] || { reply "Usage: $prefix <prompt> — e.g. '$prefix what is Nvidia'" "$msg_id"; return; }
        session="telegram-${TELEGRAM_CHAT_ID}"
        if [[ "$prompt" =~ ^/?reset[[:space:]]*$ ]]; then
            if pos ai gemini sessions reset "$session" >/dev/null 2>&1; then
                reply "Memory cleared." "$msg_id"
            else
                reply "AI error: could not clear memory" "$msg_id"
            fi
            return
        fi
        log "$prefix: $prompt"
        if [ -n "$reply_text" ]; then
            prompt="[Reply context — the message you are replying to]\n${reply_text}\n\n${prompt}"
        fi
        if answer="$(timeout 120 pos ai gemini ask --no-command-execution --session "$session" --system "$AI_SYSTEM" "$prompt" 2>&1)"; then
            reply "$(strip_markdown "$answer")" "$msg_id"
        else
            [ -n "$answer" ] || answer="timed out after 120s"
            reply "AI error: $answer" "$msg_id"
        fi
        return
    fi
    shopt -u nocasematch
    value="$(map_get "$text")"
    if [ -z "$value" ]; then
        reply "Unknown command: $text (send /help)" "$msg_id"
        return
    fi
    if [ "${value#"$QUIET_PREFIX "}" != "$value" ]; then
        quiet=1
        value="${value#"$QUIET_PREFIX "}"
    fi
    log "exec: $text"
    run_and_reply "$value" "$msg_id" 60 "$quiet"
}

# ── single-instance guard ──────────────────────────────────────
# flock(1) on a runtime lockfile — the kernel drops the lock when the process
# dies, so there is no stale-lock/pidfile bookkeeping and the systemd
# Restart=always unit restarts cleanly. Two getUpdates loops on one bot token
# cause Telegram 409 conflicts and command stealing, so a second --run fails
# closed instead of racing the active listener.
LOCK_FILE="${XDG_RUNTIME_DIR:-/tmp}/pos-telegram-listener.lock"

acquire_lock() {
    exec 9>"$LOCK_FILE"
    flock -n 9 || err "listener already running (single instance) — check: systemctl --user status pos-telegram-listener"
}

lock_held() {
    # Non-blocking probe: acquiring then dropping the flock in a subshell
    # succeeds only when nobody else holds it. Returns 0 when held.
    if ( flock -n 9 ) 9>"$LOCK_FILE" 2>/dev/null; then
        return 1
    fi
    return 0
}

run_daemon() {
    command -v flock &>/dev/null || err "flock not found (install util-linux)"
    acquire_lock
    command -v jq &>/dev/null || err "jq not found (install jq — in preinstall PACKAGES)"
    load_config
    [ -n "${TELEGRAM_BOT_TOKEN:-}" ] || err "No bot token — run 'pos config telegram'"
    [ -n "${TELEGRAM_CHAT_ID:-}" ] || err "No chat id — run 'pos config telegram'"
    if [ -n "${TELEGRAM_OWNER_ID:-}" ]; then
        log "owner id ${TELEGRAM_OWNER_ID} — commands authorized"
    else
        warn "TELEGRAM_OWNER_ID unset — chat commands WILL BE IGNORED (fail-closed); set it with 'pos config telegram'"
    fi
    sync_bot_commands || true

    # Resume from the last persisted update offset so unconfirmed updates are
    # not re-delivered in a burst after a crash/restart (systemd Restart=always
    # used to restart from 0 and re-run duplicate commands).
    local offset
    offset="$(load_offset)"
    log "listener running (chat ${TELEGRAM_CHAT_ID}, owner ${TELEGRAM_OWNER_ID:-unset}, offset ${offset}) — Ctrl+C to stop"
    # No SIGCHLD reaper here: `wait -n` inside a CHLD trap is unreliable on
    # bash 5.2 (see the note near the _CMD_* arrays). reap_commands handles
    # finished children after each polling cycle.
    trap 'kill $(jobs -p) 2>/dev/null; rm -f /tmp/pos-cmd.* 2>/dev/null; wait 2>/dev/null; exit 0' TERM INT
    while true; do
        local resp n i
        resp="$(curl -fsS -m 45 "${API}/bot${TELEGRAM_BOT_TOKEN}/getUpdates" \
            --data-urlencode "timeout=30" \
            --data-urlencode "offset=${offset}" \
            --data-urlencode 'allowed_updates=["message"]' 2>/dev/null)" || { sleep 5; continue; }
        if [ "$(printf '%s' "$resp" | jq -r '.ok // false')" != "true" ]; then
            sleep 5
            continue
        fi
        n="$(printf '%s' "$resp" | jq -r '.result | length')"
        for ((i=0; i<n; i++)); do
            local u text chat from_id msg_id reply_text
            u="$(printf '%s' "$resp" | jq -r ".result[$i].update_id")"
            text="$(printf '%s' "$resp" | jq -r ".result[$i].message.text // empty")"
            chat="$(printf '%s' "$resp" | jq -r ".result[$i].message.chat.id // empty")"
            from_id="$(printf '%s' "$resp" | jq -r ".result[$i].message.from.id // empty")"
            msg_id="$(printf '%s' "$resp" | jq -r ".result[$i].message.message_id // empty")"
            reply_text="$(printf '%s' "$resp" | jq -r ".result[$i].message.reply_to_message.text // .result[$i].message.reply_to_message.caption // empty")"
            offset=$((u + 1))
            save_offset "$offset"
            [ -n "$text" ] || continue
            if [ -z "${TELEGRAM_OWNER_ID:-}" ]; then
                warn "TELEGRAM_OWNER_ID unset — ignoring command (set it with 'pos config telegram')"
                continue
            fi
            # Fail-closed owner check: the message must be in the owner chat
            # AND sent by the owner account. Previously a chat-id OR sender-id
            # match was enough — anyone who knew the chat id could run commands.
            if [ "$chat" != "$TELEGRAM_CHAT_ID" ] || [ "$from_id" != "$TELEGRAM_OWNER_ID" ]; then
                warn "ignoring message in chat ${chat:-?} from ${from_id:-?} (not the owner chat/account)"
                continue
            fi
            handle_message "$text" "$msg_id" "$reply_text"
        done
        # Collect output from finished background commands and send replies.
        reap_commands
    done
}

case "${1:-}" in
    -h|--help) usage ;;
    --enable)  enable_service ;;
    --disable) disable_service ;;
    --status)  status ;;
    --sync-commands) sync_bot_commands ;;
    --run)     run_daemon ;;
    prefix)    prefix_cmd "${@:2}" ;;
    "")        ui ;;
    *)         err "Unknown option '$1' (see --help)" ;;
esac
