#!/bin/bash
# Author: Koushik Sen (ksen@berkeley.edu)
# Contributors:
# Koushik Sen (ksen@berkeley.edu)
# add your name here
#
# Deploy *this* KISS Sorcar checkout to a remote Linux machine and publish its
# web app (``kiss-web``) so it is reachable from any machine in the world.
#
# Usage:  ./sorcar-cloud user@ip-address
#
# Flow:
#   1. Check SSH connectivity to user@ip-address, and that no task is running
#      there: this deploy restarts the remote web app, which would kill one
#      mid-step (SORCAR_FORCE_RESTART=1 says go ahead anyway).
#   2. Copy ~/.ssh/ so the remote can fetch and push git as you, and hop to
#      other hosts as you.  It comes before the sync because the deployment
#      pulls its own history from GitHub over that key.  ``authorized_keys``
#      is never overwritten — that file is what lets you SSH *into* the
#      remote — and a file of the remote's own that this copy would replace
#      (its own key, its own ssh config) is moved into
#      ~/.kiss/ssh-replaced-<time>/ rather than destroyed: a private key
#      cannot be reconstructed.
#   3. Put this checkout and ~/<project>/ on the remote machine in total sync
#      through ``origin``, by way of scripts/sync-repo.sh: every branch here
#      is pushed to origin, the remote checkout is created (or updated) from
#      origin and pushes back what it gained since the last deploy, and this
#      checkout then collects that.  Uncommitted work on either side becomes
#      a commit first, because only commits travel through origin; a branch
#      that diverged is merged.  A branch that cannot be merged is named and
#      left as it is on both sides — which stops the deploy when it is the
#      branch being deployed, because a deployment on a commit nobody asked
#      for is worse than one that stops and explains itself.  Nothing is
#      force-pushed and no branch is deleted anywhere.
#      The remote ends up a repository you can commit and push
#      from — same remotes, shared history, clean ``git status`` — which is
#      also what install.sh needs (it packages the extension with
#      ``git ls-files``).  What a deploy no longer carries is the files git
#      does not track: ``.venv`` (a macOS virtualenv is useless on Linux
#      anyway), ``tmp/``, build output.  A ``kiss/wt-*`` branch is never
#      deployed: the agent treats such a branch as one of its own worktrees
#      and reclaiming it would delete the deployment.
#   4. Copy the ~/*rc file that holds the API keys (the one with the most
#      ``FOO_API_KEY=`` style lines) into ~/.kiss/ — not into the remote's
#      home directory, which has shell files of its own that a copy of this
#      machine's would destroy — and distill those lines into
#      ~/.kiss/api_keys.env (sourced by bash) and ~/.kiss/api_keys.systemd.env
#      (read by the kiss-web service).  Sourcing a zsh rc from bash is
#      unreliable, hence the distilled copies.  scripts/install-api-keys.sh
#      then adds one delimited block to ~/.bashrc, keeping a copy of that file
#      as it was and replacing it by an atomic rename.
#   4b. Sync the task database (~/.kiss/sorcar.db) both ways on every run, so
#      the History panel on *either* machine lists every task either machine
#      ever ran, with the tasks that ran in one checkout re-pointed at the
#      other (the panel hides tasks from other workspaces by default).
#      scripts/sync-task-db.sh does the work: it first brings back what ran on
#      the remote, then sends what ran here, each pass merging only the missing
#      rows into the receiving database in place via
#      src/kiss/scripts/sync_db.py — no row is ever deleted, both web apps keep
#      running, and a task that ran on only one of the two machines ends up on
#      both.  Only a remote without a usable database (first deploy) — or a
#      merge the schemas refuse — falls back to a full copy, which stops the
#      remote web app for the swap, keeps the database it replaces as
#      sorcar.db.replaced (with the usage counters of that database carried
#      into the new one), and starts the web app again before returning.
#   5. Run install.sh in the synced folder (git, Node.js, VS Code extension
#      build).  A ``code`` CLI is required by install.sh, so code-server is
#      installed (standalone, no sudo) when the host has no editor CLI.
#   6. Build the Python environment with ``uv sync`` and start ``kiss-web`` as
#      a systemd *user* service with lingering enabled, so the web app keeps
#      running after you log out and comes back after a reboot.
#   7. Publish it worldwide: a remote password is required (kiss-web only opens
#      a Cloudflare tunnel when one is set), ``cloudflared`` is installed when
#      missing, and the resulting public https URL is verified from this
#      machine and opened in your browser.  The stable https://ntfy.sh/<topic>
#      URL the web app displays is made machine-unique first: a
#      ~/.kiss/ntfy_topic equal to this machine's (copied by an old deploy) is
#      dropped so the remote regenerates its own topic — otherwise both
#      machines publish their tunnel URLs to one topic and the URL shown on
#      the remote resolves to whichever machine posted last.
#   8. Copy this machine's GitHub.com credentials, so the agent running on the
#      remote is the same person on GitHub as you are here: it can open pull
#      requests, read private repositories and push over https, not only over
#      the ssh key of step 2.  scripts/collect-github-auth.sh reads the token
#      of every account ``gh`` is logged in to here (on macOS the token is in
#      the keychain, so copying ~/.config/gh/hosts.yml would copy account
#      names and no token at all), falling back to git's own credential
#      helpers and then to $GH_TOKEN, and checks each one against the GitHub
#      API — a revoked token is not worth shipping, and the API answers with
#      the login to store it under.  scripts/install-github-auth.sh then logs
#      those accounts in on the remote with ``gh auth login --with-token``,
#      the active one last so it stays active there too, and points git at gh
#      for github.com URLs.  The tokens travel on standard input, never as
#      arguments — the arguments of a process are readable by everyone on the
#      machine.  The accounts the remote was already logged in to are kept in
#      ~/.kiss/ first.  A machine with no GitHub credentials is a warning, not
#      a failed deploy.
#
# Environment overrides:
#   SORCAR_REMOTE_DIR   name of the remote folder (default: project name)
#   SORCAR_WEB_PORT     port kiss-web listens on remotely (default: 8787)
#   SORCAR_PASSWORD     web app password (default: reuse existing, else random)
#   SORCAR_GITHUB_USER  GitHub account used on the remote (default: ksenxx)
#   SORCAR_GIT_BRANCH   branch to deploy (default: the main repository's
#                       branch, which is this one unless this is a worktree)
#   SORCAR_GIT_NAME     git author name on the remote (default: the account)
#   SORCAR_GIT_EMAIL    git author email (default: <account>@users.noreply.github.com)
#   SORCAR_NO_BROWSER   set to 1 to skip opening the browser
#   SORCAR_SKIP_GITHUB_AUTH  set to 1 to leave this machine's GitHub.com
#                       credentials here (step 8 is skipped)
set -euo pipefail

# ---------------------------------------------------------------------------
# Logging helpers (same vocabulary as sorcar-linux / sorcar-docker)
# ---------------------------------------------------------------------------
info() { printf '\033[0;32m[INFO]\033[0m  %s\n' "$*"; }
step() { printf '\033[0;34m[STEP]\033[0m  %s\n' "$*"; }
warn() { printf '\033[1;33m[WARN]\033[0m  %s\n' "$*"; }
die()  { printf '\033[0;31m[ERR]\033[0m  %s\n' "$*" >&2; exit 1; }

# A value that survives being pasted into a remote shell command: everything is
# wrapped in single quotes, and a single quote inside ends the quoting, escapes
# itself and starts it again.  Without this, a password holding an apostrophe
# breaks the ssh command line — or, worse, extends it.
shquote() {
    printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
}

# ---------------------------------------------------------------------------
# 1. Arguments and local pre-flight checks
# ---------------------------------------------------------------------------
usage() {
    awk '/^# Usage:/{p=1} p&&/^#/{sub(/^# ?/,""); print} p&&!/^#/{exit}' "$0"
}

TARGET="${1:-}"
case "$TARGET" in
    ""|-h|--help) usage; [[ -n "$TARGET" ]] && exit 0 || exit 1 ;;
esac
[[ "$TARGET" == *"@"* ]] || warn "'$TARGET' has no 'user@' part; using the SSH default user."

WEB_PORT="${SORCAR_WEB_PORT:-8787}"
[[ "$WEB_PORT" =~ ^[0-9]+$ ]] && ((WEB_PORT >= 1 && WEB_PORT <= 65535)) \
    || die "SORCAR_WEB_PORT must be a port number, got '$WEB_PORT'."

for tool in git ssh scp rsync curl; do
    command -v "$tool" &>/dev/null || die "$tool is not installed."
done

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[[ -f "$SCRIPT_DIR/install.sh" ]] || die "No install.sh next to $0 — is this a KISS Sorcar checkout?"
[[ -f "$SCRIPT_DIR/scripts/sync-repo.sh" ]] || die "No scripts/sync-repo.sh next to $0 — step 3 needs it."
for helper in scripts/install-api-keys.sh scripts/sync-task-db.sh \
              scripts/collect-github-auth.sh scripts/install-github-auth.sh \
              src/kiss/scripts/sync_db.py src/kiss/scripts/relocate_work_dir.py \
              src/kiss/scripts/carry_over_tables.py \
              src/kiss/scripts/db_fingerprint.py \
              src/kiss/scripts/running_tasks.py \
              src/kiss/scripts/remote_config.py; do
    [[ -f "$SCRIPT_DIR/$helper" ]] || die "No $helper next to $0 — the deploy needs it."
done

# When run from a git worktree (".kiss-worktrees/kiss_wt-1785-29b4"), the
# interesting repository is the main one it belongs to: its name is the remote
# folder's name, and its branch is the branch to deploy.
main_repo_dir() {
    if [[ -f "$SCRIPT_DIR/.git" ]]; then
        local gitdir main
        gitdir="$(sed -n 's/^gitdir: //p' "$SCRIPT_DIR/.git")"
        main="$(cd "$gitdir/../../.." 2>/dev/null && pwd || true)"
        printf '%s' "${main:-$SCRIPT_DIR}"
    else
        printf '%s' "$SCRIPT_DIR"
    fi
}
MAIN_REPO="$(main_repo_dir)"
PROJECT="${SORCAR_REMOTE_DIR:-$(basename "$MAIN_REPO")}"
case "$PROJECT" in
    ""|.|..|/|*/*) die "Refusing to use '$PROJECT' as the remote folder name." ;;
esac

step "Checking SSH connectivity to $TARGET ..."
ssh -o ConnectTimeout=15 "$TARGET" 'echo ok' >/dev/null \
    || die "Cannot SSH into $TARGET. Check the address and your SSH keys."
REMOTE_HOME="$(ssh "$TARGET" 'printf %s "$HOME"')"
[[ -n "$REMOTE_HOME" && "$REMOTE_HOME" != "/" ]] || die "Could not read \$HOME on $TARGET."
REMOTE_DIR="$REMOTE_HOME/$PROJECT"
info "SSH OK — deploying $MAIN_REPO -> $TARGET:$REMOTE_DIR"

# ---------------------------------------------------------------------------
# 1b. Do not deploy on top of a task that is still running there
#
# A deploy rebuilds the Python environment under the running web app and then
# restarts it (step 7c), which kills whatever it is doing: the agent stops
# mid-step and the steps it had left are gone.  So this is checked before
# anything on the remote is touched — and by asking the remote's own history,
# because a running task writes events continuously.  The thousands of rows
# an old database holds with no end time and no recent event are not running
# tasks; see src/kiss/scripts/running_tasks.py.
# ---------------------------------------------------------------------------
LIVE_TASK_WINDOW="${SORCAR_LIVE_TASK_WINDOW:-300}"
[[ "$LIVE_TASK_WINDOW" =~ ^[0-9]+$ ]] \
    || die "SORCAR_LIVE_TASK_WINDOW must be a number of seconds, got '$LIVE_TASK_WINDOW'."
if [[ "${SORCAR_FORCE_RESTART:-}" != "1" && "$LIVE_TASK_WINDOW" != "0" ]]; then
    step "Checking whether a task is running on $TARGET ..."
    RUNNING="$(ssh "$TARGET" "python3 - \"\$HOME/.kiss/sorcar.db\" \
        $(shquote "$LIVE_TASK_WINDOW")" \
        < "$SCRIPT_DIR/src/kiss/scripts/running_tasks.py" 2>/dev/null || echo unknown)"
    RUNNING_COUNT="$(printf '%s' "$RUNNING" | head -1 | tr -d '[:space:]')"
    case "$RUNNING_COUNT" in
        0) info "No task is running on $TARGET." ;;
        ""|*[!0-9]*)
            # Not an answer.  Reading "I could not look" as "nothing is
            # running" is how a deploy kills the task it was told to look for.
            die "Could not tell whether a task is running on $TARGET (answer:" \
                "'${RUNNING_COUNT:-none}'). Fix the connection, or the database" \
                "there, or set SORCAR_FORCE_RESTART=1 to deploy anyway."
            ;;
        *)
            printf '%s\n' "$RUNNING" | tail -n +2 | sed 's/^/         task /'
            die "$RUNNING_COUNT task(s) are still running on $TARGET, and this deploy" \
                "would restart the web app and kill them. Wait for them to finish," \
                "or set SORCAR_FORCE_RESTART=1 to deploy anyway."
            ;;
    esac
fi

# ---------------------------------------------------------------------------
# 2. Copy ~/.ssh/ — identity only, never authorized_keys
#
# Before the sync, not after it: the deployment fetches and pushes its own
# history over this key, and on a first deploy the host has none.
# ---------------------------------------------------------------------------
if [[ -d "$HOME/.ssh" ]]; then
    step "Copying ~/.ssh/ to $TARGET (keeping the remote's authorized_keys) ..."
    # A private key cannot be reconstructed, and the remote may well have one
    # of its own under a name this machine also uses (id_ed25519, config,
    # known_hosts).  --backup moves every file this copy would overwrite into
    # a directory of its own instead of destroying it; the directory is
    # outside ~/.ssh so ssh never reads what is in it, and rsync only creates
    # it if there was something to put there.
    SSH_BACKUP="$REMOTE_HOME/.kiss/ssh-replaced-$(date -u +%Y%m%dT%H%M%SZ)"
    ssh "$TARGET" 'mkdir -p "$HOME/.ssh" "$HOME/.kiss" && chmod 700 "$HOME/.ssh" "$HOME/.kiss"'
    rsync -aHz -e ssh --backup --backup-dir="$SSH_BACKUP" \
        --exclude='authorized_keys' --exclude='authorized_keys2' \
        --exclude='environment' --exclude='*.sock' --exclude='.DS_Store' \
        "$HOME/.ssh/" "$TARGET:.ssh/"
    # ssh refuses to use a key whose permissions are group/world readable.
    ssh "$TARGET" "SSH_BACKUP=$(shquote "$SSH_BACKUP") bash -s" <<'FIXSSH'
set -e
chmod 700 "$HOME/.ssh"
find "$HOME/.ssh" -type f -exec chmod 600 {} +
find "$HOME/.ssh" -type f \( -name '*.pub' -o -name 'known_hosts*' \) -exec chmod 644 {} +
if [ -d "$SSH_BACKUP" ]; then
    chmod -R go-rwx "$SSH_BACKUP"
    echo "[$(hostname -s)] kept the ssh files this copy replaced in ${SSH_BACKUP/#$HOME/~}:"
    find "$SSH_BACKUP" -type f | sed "s|^$SSH_BACKUP/|         |"
fi
FIXSSH
    info "SSH identity copied to $REMOTE_HOME/.ssh."
else
    warn "No local ~/.ssh directory — skipping."
fi

# ---------------------------------------------------------------------------
# 3. Sync this checkout and the remote one through origin
#
# Git facts first, because the sync needs them: the branch to deploy, and the
# identity the remote commits and pushes as.
#
# The deployment is a main checkout, so it must not sit on a "kiss/wt-*"
# branch: the agent treats such a branch as one of its own worktrees and
# reclaims it — which deletes the directory.
# ---------------------------------------------------------------------------
GITHUB_USER="${SORCAR_GITHUB_USER:-ksenxx}"
# The main repository's branch, not this worktree's: a worktree's branch
# belongs to the agent running there, and its files are that branch's work.
GIT_BRANCH="${SORCAR_GIT_BRANCH:-$(git -C "$MAIN_REPO" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)}"
case "$GIT_BRANCH" in
    kiss/wt-*|HEAD|"")
        warn "'$GIT_BRANCH' cannot be checked out remotely — deploying 'main' instead."
        GIT_BRANCH=main
        ;;
esac
# Commits made on the remote are authored as the GitHub account, so GitHub
# attributes them to it (the noreply address is the one GitHub maps to a user).
GIT_NAME="${SORCAR_GIT_NAME:-$GITHUB_USER}"
GIT_EMAIL="${SORCAR_GIT_EMAIL:-$GITHUB_USER@users.noreply.github.com}"

# scripts/sync-repo.sh does the work on both sides (see its header): this
# checkout and the remote one meet on origin, every branch of both is
# mirrored, and uncommitted work is committed before it travels.  The
# repository that is synced is the main one — a worktree shares its refs, and
# a worktree's branch is not deployable anyway.
step "Syncing $MAIN_REPO and $TARGET:$REMOTE_DIR through origin (branch $GIT_BRANCH) ..."
SORCAR_GITHUB_USER="$GITHUB_USER" SORCAR_GIT_NAME="$GIT_NAME" \
SORCAR_GIT_EMAIL="$GIT_EMAIL" \
    bash "$SCRIPT_DIR/scripts/sync-repo.sh" \
        "$TARGET" "$MAIN_REPO" "$REMOTE_DIR" "$GIT_BRANCH" \
    || die "Could not sync the project with $TARGET."
info "Project synced ($REMOTE_DIR is on $GIT_BRANCH, at origin's commit for it)."

# ---------------------------------------------------------------------------
# 4. Copy the ~/*rc file that contains the API keys
# ---------------------------------------------------------------------------
KEY_RE='^[[:space:]]*(export[[:space:]]+)?[A-Z][A-Z0-9_]*='
find_rc_file() {
    local best="" best_n=0 candidate n
    for candidate in "$HOME"/.*rc; do
        [[ -f "$candidate" ]] || continue
        n="$(grep -cE "${KEY_RE}.*" "$candidate" 2>/dev/null || true)"
        grep -qE '(API_KEY|_TOKEN)[[:space:]]*=' "$candidate" 2>/dev/null || continue
        if ((n > best_n)); then best="$candidate"; best_n="$n"; fi
    done
    printf '%s' "$best"
}
RC_FILE="$(find_rc_file)"
[[ -n "$RC_FILE" ]] || die "No ~/*rc file with API keys found — nothing to give the remote agent."

TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT

# Distil the credential lines.  ~/.kiss/api_keys.env is bash syntax; the
# systemd variant drops "export", shell-environment variables and any value
# containing $ or ` (systemd does not expand them, so a "PATH=x:$PATH" line
# would corrupt the service environment).
grep -E "$KEY_RE" "$RC_FILE" \
    | sed -E 's/^[[:space:]]*//; s/^export[[:space:]]+//' > "$TMP_DIR/keys.raw"
awk '{print "export " $0}' "$TMP_DIR/keys.raw" > "$TMP_DIR/api_keys.env"
grep -vE '^(PATH|MANPATH|LD_LIBRARY_PATH|DYLD_LIBRARY_PATH|PS1|PROMPT|LANG|LC_[A-Z]+|HOME|SHELL|TERM|EDITOR)=' \
    "$TMP_DIR/keys.raw" | grep -vE '[$`]' > "$TMP_DIR/api_keys.systemd.env" || true
KEY_COUNT="$(wc -l < "$TMP_DIR/api_keys.env" | tr -d ' ')"

step "Copying $(basename "$RC_FILE") and $KEY_COUNT exported variables to $TARGET ..."
ssh "$TARGET" 'mkdir -p "$HOME/.kiss" && chmod 700 "$HOME/.kiss"'
# The rc file goes into ~/.kiss, not into the remote's home: a home directory
# already holds a .bashrc or .zshrc of its own, and dropping this machine's
# copy on top of it destroys the shell configuration of the host (in the
# .bashrc case, the very file the next step edits).  It holds every API key,
# so it is also kept unreadable by anyone else — the copy in the home
# directory was world-readable.
scp -q "$RC_FILE" "$TARGET:.kiss/$(basename "$RC_FILE")"
scp -q "$TMP_DIR/api_keys.env" "$TMP_DIR/api_keys.systemd.env" "$TARGET:.kiss/"
# Steps 7a and 7c run these two on the remote.  They are shipped rather than
# run out of the synced checkout, so that deploying a branch older than they
# are still gets the version that keeps the rest of ~/.kiss/config.json, and
# the one that looks for a running task before the web app is restarted.
scp -q "$SCRIPT_DIR/src/kiss/scripts/remote_config.py" \
       "$SCRIPT_DIR/src/kiss/scripts/running_tasks.py" "$TARGET:.kiss/"
ssh "$TARGET" "chmod 600 \"\$HOME/.kiss/$(basename "$RC_FILE")\""
# Make interactive and non-interactive bash sessions (which is what the agent
# and its tools run under) see the keys; see scripts/install-api-keys.sh for
# how ~/.bashrc is edited without damaging what is in it.
ssh "$TARGET" 'bash -s' < "$SCRIPT_DIR/scripts/install-api-keys.sh"
info "API keys installed (~/.kiss/api_keys.env, sourced from ~/.bashrc)."

# ---------------------------------------------------------------------------
# 4b. Sync the task database both ways, so the History panel on either machine
#     lists every task either machine ever ran.  scripts/sync-task-db.sh first
#     merges the remote's tasks into this machine's database and then this
#     machine's into the remote's, each pass moving only the missing rows (via
#     src/kiss/scripts/sync_db.py, both web apps left running, no row ever
#     deleted).  Bringing the remote's tasks back first is also what makes the
#     full-copy fallback safe: by the time it can run, the tasks that ran only
#     on the remote are already here.
# ---------------------------------------------------------------------------
#     A history that could not be synced is worth a warning, not a failed
#     deploy: the script leaves both databases as it found them, so the next
#     run picks up where this one stopped, and the web app about to be
#     installed works either way.
step "Syncing the task database with $TARGET (both ways) ..."
bash "$SCRIPT_DIR/scripts/sync-task-db.sh" "$TARGET" "$MAIN_REPO" "$REMOTE_DIR" \
    || warn "The task databases are not in sync (see above); continuing the deploy."

# ---------------------------------------------------------------------------
# 5-7. Remote bootstrap: install.sh, uv sync, kiss-web service, public URL.
#
# The heredoc is quoted so nothing expands locally; parameters travel through
# the environment on the ssh command line.  A pseudo-terminal (-t) is not used
# because install.sh must not block on prompts.
# ---------------------------------------------------------------------------
PASSWORD="${SORCAR_PASSWORD:-}"

# The ntfy topic is the stable half of the web app's URL: the UI shows
# https://ntfy.sh/<topic>, where kiss-web posts its current tunnel URL, so
# every machine must own a distinct topic.  This machine's topic is shipped
# so the remote can drop a copy of it (step 7a'); kiss-web then regenerates
# a topic from the remote machine's own identity.
LOCAL_NTFY_TOPIC="$(cat "$HOME/.kiss/ntfy_topic" 2>/dev/null | tr -d '[:space:]' || true)"

step "Installing KISS Sorcar on $TARGET (this takes a few minutes) ..."
ssh "$TARGET" "REMOTE_DIR=$(shquote "$REMOTE_DIR") WEB_PORT=$(shquote "$WEB_PORT") \
    PASSWORD=$(shquote "$PASSWORD") LOCAL_NTFY_TOPIC=$(shquote "$LOCAL_NTFY_TOPIC") \
    LIVE_TASK_WINDOW=$(shquote "$LIVE_TASK_WINDOW") \
    FORCE_RESTART=$(shquote "${SORCAR_FORCE_RESTART:-}") \
    bash -s" <<'REMOTE'
set -euo pipefail
export PATH="$HOME/.local/bin:$PATH"
[ -f "$HOME/.kiss/api_keys.env" ] && . "$HOME/.kiss/api_keys.env"
rinfo() { printf '\033[0;36m[%s]\033[0m %s\n' "$(hostname -s)" "$*"; }

cd "$REMOTE_DIR"

# --- 5a. C toolchain: some Python wheels are built from source by uv --------
if ! command -v cc >/dev/null 2>&1; then
    rinfo "Installing build-essential (needed to compile Python wheels)..."
    sudo -n apt-get install -y build-essential >/dev/null 2>&1 \
        || rinfo "WARNING: could not install build-essential; continuing."
fi
command -v git >/dev/null 2>&1 || { echo "ERROR: git is missing on this host." >&2; exit 1; }

# --- 5b. The repository is already here --------------------------------------
# Step 3 (scripts/sync-repo.sh) created this checkout from origin, put it on
# the branch being deployed, and committed whatever an agent left uncommitted
# here — so there is a real repository to commit and push from, which is also
# what install.sh needs (it packages the extension with ``git ls-files``).
[ -d .git ] || { echo "ERROR: $REMOTE_DIR is not a git repository." >&2; exit 1; }
rinfo "Repository: $(git log -1 --format='%h %s' 2>/dev/null || echo 'no commits') on $(git symbolic-ref -q --short HEAD || echo 'detached HEAD')"

# --- 5c. A `code` CLI, which install.sh requires ---------------------------
# On a headless server there is no desktop VS Code.  code-server ships the
# same extension CLI, so a shim forwards the extension subcommands to it and
# swallows editor launches (this script owns what runs).
#
# Two different editors can attach to this host, and each reads its own
# extensions directory:
#   * code-server (the browser IDE) .... ~/.local/share/code-server/extensions
#   * VS Code Remote - SSH ............. ~/.vscode-server/extensions
# Installing only into code-server leaves Remote-SSH windows without the
# KISS Sorcar panel, so ``--install-extension``/``--uninstall-extension``
# target both.  ~/.vscode-server is populated with the Remote-SSH server's own
# CLI when someone has already connected; before that first connection,
# code-server's compatible CLI pointed at that directory pre-seeds it, so the
# extension is there the moment Remote-SSH first connects.
#
# The shim is rewritten (not only created) on every deploy so existing
# deployments pick up fixes; a real ``code`` binary is never overwritten.
CODE_PATH="$(command -v code 2>/dev/null || true)"
if [ -z "$CODE_PATH" ] || grep -q 'kiss-sorcar code shim' "$CODE_PATH" 2>/dev/null; then
    if ! command -v code-server >/dev/null 2>&1; then
        rinfo "Installing code-server (standalone, no sudo)..."
        curl -fsSL https://code-server.dev/install.sh \
            | sh -s -- --method=standalone --prefix="$HOME/.local" >/dev/null
    fi
    mkdir -p "$HOME/.local/bin"
    cat > "$HOME/.local/bin/code" <<'SHIM'
#!/bin/bash
# kiss-sorcar code shim — stands in for the VS Code CLI on a headless server.
# Newest CLI shipped by a VS Code Remote-SSH server connection, if any.
vscode_server_cli() {
    ls -t "$HOME"/.vscode-server/cli/servers/*/server/bin/code-server \
          "$HOME"/.vscode-server/bin/*/bin/code-server 2>/dev/null | head -1
}
case "${1:-}" in
    --install-extension|--uninstall-extension)
        code-server "$@"
        status=$?
        cli="$(vscode_server_cli)"
        [ -n "$cli" ] || cli="$(command -v code-server)"
        mkdir -p "$HOME/.vscode-server/extensions"
        "$cli" --extensions-dir "$HOME/.vscode-server/extensions" "$@" || status=$?
        exit $status
        ;;
    --list-extensions|--version|--locate-extension)
        exec code-server "$@" ;;
    *) exit 0 ;;
esac
SHIM
    chmod +x "$HOME/.local/bin/code"
    hash -r
fi

# --- 5d. install.sh --------------------------------------------------------
# KISS_SKIP_UPDATE: never ``git pull`` over the tree we just shipped.
# KISS_SKIP_LAUNCH: no editor launch; this script starts the web app instead.
rinfo "Running install.sh ..."
KISS_SKIP_UPDATE=1 KISS_SKIP_LAUNCH=1 bash "$REMOTE_DIR/install.sh"

# --- 6a. Python environment ------------------------------------------------
if ! command -v uv >/dev/null 2>&1; then
    rinfo "Installing uv ..."
    curl -LsSf https://astral.sh/uv/install.sh | sh >/dev/null
    hash -r
fi
if [ -d .venv ] && ! .venv/bin/python -c pass >/dev/null 2>&1; then
    rinfo "Removing an unusable .venv ..."
    rm -rf .venv
fi
rinfo "Building the Python environment (uv sync) ..."
uv sync
[ -x .venv/bin/kiss-web ] || { echo "ERROR: uv sync did not produce .venv/bin/kiss-web" >&2; exit 1; }
# Import the model SDKs now: a half-populated environment otherwise only
# surfaces when a task fails with "Anthropic SDK not installed".
.venv/bin/python -c 'import kiss, anthropic, openai' \
    || { echo "ERROR: the Python environment is incomplete (see uv sync above)" >&2; exit 1; }

# --- 7a. Password + work_dir in ~/.kiss/config.json ------------------------
# kiss-web only opens a Cloudflare tunnel when a password is configured, so a
# password is what makes the web app both reachable worldwide and protected.
python3 "$HOME/.kiss/remote_config.py" \
    "$HOME/.kiss/config.json" "$REMOTE_DIR" "$PASSWORD"

# --- 7a'. A machine-unique ntfy topic, so the URL the web app shows is its
#          own.  The web app presents https://ntfy.sh/<topic> (from
#          ~/.kiss/ntfy_topic) as its stable URL and posts its tunnel URL
#          there.  When an old deploy copied the laptop's topic file here,
#          both machines published to one topic, and the URL shown on this
#          host resolved to whichever machine posted last — usually the
#          laptop.  Dropping the copied file makes kiss-web regenerate a
#          topic from this machine's hostname+MAC; an already-unique topic
#          is kept so the remote's published URL stays stable.
if [ -n "${LOCAL_NTFY_TOPIC:-}" ] \
   && [ "$(cat "$HOME/.kiss/ntfy_topic" 2>/dev/null | tr -d '[:space:]')" = "$LOCAL_NTFY_TOPIC" ]; then
    rinfo "Dropping ~/.kiss/ntfy_topic copied from the local machine (a unique one is regenerated)..."
    rm -f "$HOME/.kiss/ntfy_topic"
fi

# --- 7b. cloudflared: the tunnel that makes the web app world-reachable ----
if ! command -v cloudflared >/dev/null 2>&1; then
    rinfo "Installing cloudflared ..."
    case "$(uname -m)" in
        x86_64|amd64) CF_ARCH=amd64 ;;
        aarch64|arm64) CF_ARCH=arm64 ;;
        *) CF_ARCH="" ;;
    esac
    if [ -n "$CF_ARCH" ]; then
        curl -fsSL -o "$HOME/.local/bin/cloudflared" \
            "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$CF_ARCH"
        chmod +x "$HOME/.local/bin/cloudflared"
        hash -r
    fi
fi
command -v cloudflared >/dev/null 2>&1 \
    || { echo "ERROR: cloudflared is required for worldwide access" >&2; exit 1; }

# --- 6b/7c. kiss-web as a systemd user service -----------------------------
# A user service plus lingering keeps the web app alive after logout and
# restarts it after a crash or reboot — required for "reachable from anywhere,
# any time".  The service binds 0.0.0.0:$WEB_PORT and publishes the tunnel.
rinfo "Starting kiss-web as a systemd user service on port $WEB_PORT ..."
mkdir -p "$HOME/.config/systemd/user" "$HOME/.kiss"
cat > "$HOME/.config/systemd/user/kiss-web.service" <<UNIT
[Unit]
Description=KISS Sorcar Remote Web Server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=$REMOTE_DIR/.venv/bin/kiss-web --workdir $REMOTE_DIR
WorkingDirectory=$REMOTE_DIR
EnvironmentFile=-%h/.kiss/api_keys.systemd.env
Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
Restart=always
RestartSec=5
StandardOutput=append:%h/.kiss/kiss-web-stdout.log
StandardError=append:%h/.kiss/kiss-web-stderr.log

[Install]
WantedBy=default.target
UNIT
loginctl enable-linger "$(id -un)" >/dev/null 2>&1 || true
systemctl --user daemon-reload
systemctl --user enable kiss-web.service >/dev/null 2>&1 || true
# Retire the previous tunnel before restarting: kiss-web adopts a cloudflared
# it finds in ~/.kiss/cloudflared.pid and republishes its URL, and that URL
# stops resolving (HTTP 530) once the old tunnel dies with its owner.
#
# Only this deployment's tunnel is retired: the process kiss-web recorded (if
# it is still a cloudflared — a recorded pid outlives the process it named, and
# the number is eventually given to something else), and then any cloudflared
# publishing this very port.  This host may well be running a tunnel for
# something else, and killing every "cloudflared tunnel" it owns — which is
# what a bare pattern does — takes that down too.
#
# A task may have started in the minutes this deploy has been running; the
# restart below would end it mid-step.
if [ "${FORCE_RESTART:-}" != "1" ] && [ "${LIVE_TASK_WINDOW:-300}" != "0" ]; then
    # The braces keep a failing probe from ending the pipeline (and, under
    # ``set -e``, this script) before its answer has been read.
    LIVE=$( { python3 "$HOME/.kiss/running_tasks.py" "$HOME/.kiss/sorcar.db" \
                  "${LIVE_TASK_WINDOW:-300}" || true; } | head -1 | tr -d '[:space:]')
    if [ "$LIVE" != "0" ]; then
        echo "ERROR: '$LIVE' task(s) are running on this host now (none were when" \
             "the deploy started); restarting the web app would kill them. Deploy" \
             "again when they are done, or with SORCAR_FORCE_RESTART=1." >&2
        exit 1
    fi
fi
systemctl --user stop kiss-web.service >/dev/null 2>&1 || true
OLD_TUNNEL="$(python3 -c 'import json, sys
try:
    pid = int(json.load(open(sys.argv[1]))["pid"])
except Exception:
    pass
else:
    print(pid if pid > 1 else "")' "$HOME/.kiss/cloudflared.pid" 2>/dev/null || true)"
if [ -n "$OLD_TUNNEL" ] \
   && [ "$(ps -o comm= -p "$OLD_TUNNEL" 2>/dev/null | xargs -r basename)" = cloudflared ]; then
    kill "$OLD_TUNNEL" >/dev/null 2>&1 || true
fi
pkill -f "cloudflared.*--url .*:$WEB_PORT" >/dev/null 2>&1 || true
rm -f "$HOME/.kiss/cloudflared.pid" "$HOME/.kiss/remote-url.json"
systemctl --user restart kiss-web.service

# --- 7d. Wait for the local endpoint, then for the public URL --------------
for _ in $(seq 1 90); do
    code=$(curl -sk -o /dev/null -w '%{http_code}' "https://127.0.0.1:$WEB_PORT/" || true)
    [ "$code" = "200" ] && break
    sleep 1
done
[ "${code:-}" = "200" ] || {
    echo "ERROR: kiss-web did not answer on https://127.0.0.1:$WEB_PORT" >&2
    tail -20 "$HOME/.kiss/kiss-web-stderr.log" >&2 || true
    exit 1
}
rinfo "kiss-web answers on https://127.0.0.1:$WEB_PORT"
# Wait for a tunnel URL that actually answers: Cloudflare needs a few seconds
# to route a fresh quick tunnel, and the file is rewritten if the tunnel is
# replaced, so re-read it on every attempt instead of trusting the first value.
url=""
for _ in $(seq 1 60); do
    candidate=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("tunnel",""))' \
                "$HOME/.kiss/remote-url.json" 2>/dev/null || true)
    if [ -n "$candidate" ] \
       && [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$candidate/")" = "200" ]; then
        url="$candidate"
        break
    fi
    sleep 3
done
[ -n "$url" ] || {
    echo "ERROR: no reachable public tunnel URL appeared (last: ${candidate:-none})" >&2
    tail -20 "$HOME/.kiss/kiss-web-stderr.log" >&2 || true
    exit 1
}
rinfo "Public tunnel is live: $url"
echo "SORCAR_PUBLIC_URL=$url"
REMOTE

# ---------------------------------------------------------------------------
# 8. Copy this machine's GitHub.com credentials
#
# The ssh key of step 2 makes the remote you for git over ssh.  This makes it
# you for everything else GitHub offers: ``gh pr create``, a private
# repository, a push over https.  The token never becomes an argument of a
# command on either machine (arguments are readable by every account on the
# host, standard input is not) and it never becomes a file here: it is held in
# a shell variable and piped straight into the installer.
#
# Not having any is worth a warning and no more.  A deployment that cannot
# open pull requests still runs tasks.
# ---------------------------------------------------------------------------
if [[ "${SORCAR_SKIP_GITHUB_AUTH:-}" == "1" ]]; then
    info "Leaving the GitHub.com credentials here (SORCAR_SKIP_GITHUB_AUTH=1)."
else
    step "Copying this machine's GitHub.com credentials to $TARGET ..."
    if GH_PAYLOAD="$(bash "$SCRIPT_DIR/scripts/collect-github-auth.sh")" \
       && [[ -n "$GH_PAYLOAD" ]]; then
        # One guard around all three: a deploy is not abandoned because the
        # copy of a helper script failed, any more than because the install
        # of the credentials did.
        {
            ssh "$TARGET" 'mkdir -p "$HOME/.kiss" && chmod 700 "$HOME/.kiss"' \
            && scp -q "$SCRIPT_DIR/scripts/install-github-auth.sh" "$TARGET:.kiss/" \
            && printf '%s\n' "$GH_PAYLOAD" \
               | ssh "$TARGET" 'bash "$HOME/.kiss/install-github-auth.sh"'
        } || warn "The GitHub.com credentials were not installed (see above); continuing."
        unset GH_PAYLOAD
    else
        warn "No GitHub.com credentials on this machine, so the remote agent gets none:" \
             "it will not be able to use gh or push over https. Run 'gh auth login' here."
    fi
fi

# ---------------------------------------------------------------------------
# 9. Read back the public URL / password, verify from this machine, open it.
#    The remote's stdout above is echoed for the user; the two SORCAR_* lines
#    are re-read here over a fresh, quiet ssh call.
# ---------------------------------------------------------------------------
PUBLIC_URL="$(ssh "$TARGET" 'python3 -c "import json;print(json.load(open(\"$HOME/.kiss/remote-url.json\"))[\"tunnel\"])"')"
PASSWORD="$(ssh "$TARGET" 'python3 -c "import json;print(json.load(open(\"$HOME/.kiss/config.json\")).get(\"remote_password\",\"\"))"')"
[[ -n "$PUBLIC_URL" ]] || die "Could not read the public URL from $TARGET."

step "Verifying $PUBLIC_URL from this machine ..."
for _ in $(seq 1 30); do
    HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$PUBLIC_URL/" || true)"
    [[ "$HTTP_CODE" == "200" ]] && break
    sleep 2
done
[[ "$HTTP_CODE" == "200" ]] \
    || warn "The public URL answered HTTP $HTTP_CODE — Cloudflare may still be propagating."

GIT_STATE="$(ssh "$TARGET" "cd $(shquote "$REMOTE_DIR") && git log -1 --format='%h %s' && git status --porcelain | wc -l" 2>/dev/null | tr '\n' ' ' || true)"

echo ""
echo "  ┌──────────────────────────────────────────────────────────────"
echo "  │  KISS Sorcar web app (reachable from anywhere)"
echo "  │  URL:       $PUBLIC_URL"
echo "  │  Password:  $PASSWORD"
echo "  │  Host:      $TARGET   Folder: $REMOTE_DIR"
echo "  │  Service:   systemctl --user status kiss-web   (log: ~/.kiss/kiss-web-stderr.log)"
echo "  │  Git:       branch $GIT_BRANCH, commit $GIT_STATE(uncommitted files)"
echo "  │             commits as $GIT_NAME <$GIT_EMAIL>, pushes as $GITHUB_USER over ssh"
echo "  └──────────────────────────────────────────────────────────────"
echo ""

if [[ "${SORCAR_NO_BROWSER:-}" != "1" ]]; then
    case "$(uname -s)" in
        Darwin) open "$PUBLIC_URL" >/dev/null 2>&1 || true ;;
        Linux)  command -v xdg-open >/dev/null 2>&1 && (xdg-open "$PUBLIC_URL" >/dev/null 2>&1 &) || true ;;
    esac
fi
info "Done."
