#!/bin/sh
# ompstats collector — install or upgrade, macOS and Linux.
#
#   curl -fsSL https://ompstats.com/install.sh | sh
#
# Idempotent: existing config, privacy settings and cursors are preserved.
# Run once to enable verified automatic updates before subsequent pushes.
# Without a config, the script asks for a token. Non-interactive:
#   curl -fsSL https://ompstats.com/install.sh | OMPSTATS_TOKEN=omps_... sh
set -eu

SERVER="${OMPSTATS_SERVER:-https://ompstats.com}"
CFG_DIR="${OMPSTATS_DIR:-$HOME/.ompstats}"
CFG="$CFG_DIR/config.json"

say() { printf '==> %s\n' "$*"; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }

MENUBAR_CHOICE="${OMPSTATS_MENUBAR:-}"
case "$MENUBAR_CHOICE" in
	""|on|off) ;;
	*) die "OMPSTATS_MENUBAR must be on or off" ;;
esac
if [ -n "$MENUBAR_CHOICE" ] && [ "$(uname -s)" != Darwin ]; then
	die "the native subscription menu is available on macOS only"
fi

# ---- bun ------------------------------------------------------------------
# Not always on PATH: cron and launchd start with a bare environment, so the
# schedule below records an absolute path rather than relying on lookup.
find_bun() {
	for candidate in "$HOME/.bun/bin/bun" "$(command -v bun 2>/dev/null || true)" "$HOME/.local/bin/bun" /opt/homebrew/bin/bun /usr/local/bin/bun; do
		[ -n "$candidate" ] && [ -x "$candidate" ] || continue
		"$candidate" -e 'const [major, minor, patch] = Bun.version.split(".").map(Number); process.exit(major > 1 || (major === 1 && (minor > 3 || (minor === 3 && patch >= 14))) ? 0 : 1)' >/dev/null 2>&1 || continue
		printf '%s' "$candidate"
		return 0
	done
	return 1
}

BUN="$(find_bun || true)"
if [ -z "$BUN" ]; then
	say "installing bun"
	curl -fsSL https://bun.sh/install | bash >/dev/null 2>&1 || die "bun install failed - see https://bun.sh"
	BUN="$(find_bun || true)"
	[ -n "$BUN" ] || die "bun installed but no Bun >= 1.3.14 was found on any known path"
fi
say "bun: $BUN ($("$BUN" --version))"
"$BUN" -e 'process.exit(typeof Bun.zstdDecompressSync === "function" ? 0 : 1)' >/dev/null 2>&1 ||
	die "bun is too old to read compressed Codex history; upgrade Bun to 1.3.14 or newer, then rerun this installer"
CFG_DIR="$("$BUN" -e 'console.log(require("node:path").resolve(process.argv[1]))' "$CFG_DIR")"
export OMPSTATS_DIR="$CFG_DIR"
CFG="$CFG_DIR/config.json"
if [ -z "${OMPSTATS_SERVER:-}" ] && [ -f "$CFG" ]; then
	SERVER="$("$BUN" -e 'console.log(JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).server)' "$CFG")"
fi
"$BUN" -e '
const url = new URL(process.argv[1]);
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) throw new Error("Collector installation requires HTTPS, except on loopback");
if (url.username || url.password || url.search || url.hash) throw new Error("Invalid collector server URL");
' "$SERVER"

# ---- token ----------------------------------------------------------------
# Order: existing config (upgrade), then env, then ask. Reading the answer from
# /dev/tty and not stdin is what makes the prompt work under `curl | sh`.
#
# Every token is checked against the server before anything is installed, the
# reused one included: a token revoked in the dashboard would otherwise install
# a schedule that fails silently on every run. A machine that fell behind
# because its token was revoked is exactly the machine someone re-runs this on.
check_token() {
	case "$1" in
		omps_*) ;;
		*) die "that does not look like a push token (expected omps_...)" ;;
	esac
	code="$(curl -fsS -o /dev/null -w '%{http_code}' -H "authorization: Bearer $1" "$SERVER/v1/cursor?source=omp" || true)"
	case "$code" in
		200) ;;
		401) die "$2" ;;
		*) die "cannot reach $SERVER to check the token (HTTP ${code:-none})" ;;
	esac
}

# An explicit OMPSTATS_TOKEN wins: it is how a machine whose token was revoked
# gets a fresh one without hand-editing config.json.
TOKEN="${OMPSTATS_TOKEN:-}"
if [ -n "$TOKEN" ]; then
	check_token "$TOKEN" "$SERVER rejected that token - revoked, or copied incompletely"
	FRESH_TOKEN=1
else
	if [ -f "$CFG" ]; then
		TOKEN="$(sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$CFG" | head -1)"
	fi
	if [ -n "$TOKEN" ]; then
		check_token "$TOKEN" "the token in $CFG is revoked or invalid - mint a new one in the dashboard (Instances & tokens -> New token) and re-run with OMPSTATS_TOKEN=omps_..."
		say "reusing the token already in $CFG"
	else
		[ -r /dev/tty ] || die "no token: pass OMPSTATS_TOKEN=omps_... or run without a pipe"
		printf 'Push token for %s (dashboard -> Instances & tokens -> New token): ' "$SERVER" >/dev/tty
		IFS= read -r TOKEN </dev/tty
		check_token "$TOKEN" "$SERVER rejected that token - revoked, or copied incompletely"
		FRESH_TOKEN=1
	fi
fi

# ---- managed collector ---------------------------------------------------
# Never pull over a development checkout or overlay it with downloaded files.
# Immutable releases live beside config; only the active symlink is switched.
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
say "fetching a verified collector release from $SERVER"
curl -fsSL --connect-timeout 10 --max-time 30 "$SERVER/install/release.json" -o "$TMP/release.json" ||
	die "cannot download the collector release manifest"
curl -fsSL --connect-timeout 10 --max-time 60 "$SERVER/install/source.tar.gz" -o "$TMP/source.tar.gz" ||
	die "cannot download the collector source"
# Validate before extracting or executing the bootstrap launcher. Its normal
# update path repeats these checks and performs runtime/dependency validation.
"$BUN" - "$TMP" <<'VERIFY_JS'
import * as path from "node:path";
const directory = Bun.argv[2];
const release = await Bun.file(path.join(directory, "release.json")).json();
if (!/^[a-f0-9]{40}$/.test(release.version ?? "") || !/^[a-f0-9]{64}$/.test(release.archive_sha256 ?? "")) throw new Error("Invalid release manifest");
const archive = path.join(directory, "source.tar.gz");
const bytes = await Bun.file(archive).arrayBuffer();
if (bytes.byteLength > 64 * 1024 * 1024 || new Bun.CryptoHasher("sha256").update(bytes).digest("hex") !== release.archive_sha256) throw new Error("Collector archive checksum mismatch");
for (const mode of ["tzf", "tvzf"]) {
	const result = Bun.spawnSync(["tar", mode, archive], { env: { ...Bun.env, LC_ALL: "C" } });
	if (result.exitCode !== 0) throw new Error("Cannot inspect the collector archive");
	for (const entry of result.stdout.toString().trim().split("\n")) {
		if (mode === "tzf" ? (!entry || entry.startsWith("/") || entry.split("/").includes("..")) : !["-", "d"].includes(entry[0])) throw new Error("Unsafe collector archive entry");
	}
}
VERIFY_JS
mkdir "$TMP/source"
tar xzf "$TMP/source.tar.gz" --no-same-owner --no-same-permissions -C "$TMP/source"
# The bootstrap launcher uses Effect too; load its frozen dependencies only
# after validating the publisher's archive, before executing any source.
"$BUN" install --cwd "$TMP/source" --frozen-lockfile --production ||
	die "cannot install verified collector dependencies"
"$BUN" install --cwd "$TMP/source/ompstats-usage" --frozen-lockfile --production ||
	die "cannot install verified ompstats-usage dependencies"
"$BUN" "$TMP/source/packages/agent/src/launcher.ts" install --server "$SERVER" \
	--archive "$TMP/source.tar.gz" --manifest "$TMP/release.json"
AGENT="$CFG_DIR/collector/current/packages/agent/src/index.ts"
LAUNCHER="$CFG_DIR/collector/current/packages/agent/src/launcher.ts"
say "source: $CFG_DIR/collector/current (managed; existing checkouts are untouched)"

if [ -f "$CFG" ] && [ -z "${FRESH_TOKEN:-}" ]; then
	# Keep the rest of the config (sources, privacy) exactly as the machine has it.
	say "config: $CFG (kept)"
elif [ -f "$CFG" ]; then
	# A replacement token was supplied: rewrite only that field, so the sources
	# and privacy settings this machine already has survive.
	tmpcfg="$CFG.new.$$"
	sed 's|\("token"[[:space:]]*:[[:space:]]*"\)[^"]*"|\1'"$TOKEN"'"|' "$CFG" >"$tmpcfg" || die "cannot rewrite $CFG"
	grep -q "$TOKEN" "$tmpcfg" || die "cannot rewrite the token in $CFG - edit it by hand"
	mv "$tmpcfg" "$CFG"
	say "config: $CFG (token replaced)"
else
	# Defaults stay non-private, so the choice is the machine owner's and it is
	# made once, here. OMPSTATS_PRIVACY answers it non-interactively;
	# OMPSTATS_PROJECT_SALT reuses one salt across a person's machines so a
	# project keeps the same code on all of them.
	PRIVACY="${OMPSTATS_PRIVACY:-}"
	if [ -z "$PRIVACY" ] && [ -r /dev/tty ]; then
		{
			printf '\nWhat may leave this machine?\n'
			printf '  1) standard  project folder names, error text, hostname  (default)\n'
			printf '  2) codes     project codes and error types only, no hostname\n'
			printf '  3) minimal   codes, and no tool-call or per-turn rows either\n'
			printf 'Choice [1]: '
		} >/dev/tty
		IFS= read -r choice </dev/tty
		case "$choice" in
		2) PRIVACY=codes ;;
		3) PRIVACY=minimal ;;
		*) PRIVACY=standard ;;
		esac
	fi
	"$BUN" "$AGENT" init --server "$SERVER" --token "$TOKEN" \
		--privacy "${PRIVACY:-standard}" \
		${OMPSTATS_PROJECT_SALT:+--project-salt "$OMPSTATS_PROJECT_SALT"} >/dev/null
	say "config: $CFG (created, mode 0600, privacy: ${PRIVACY:-standard})"
	if [ "${PRIVACY:-standard}" != "standard" ]; then
		SALT="$(sed -n 's/.*"project_salt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$CFG" | head -1)"
		say "project codes are on - resolve them here with: $BUN $AGENT projects"
		say "reuse this salt on your other machines: OMPSTATS_PROJECT_SALT=$SALT"
	fi
fi

# omp is detected once, for both the schedule line and the pre-push refresh.
# Same reason find_bun exists: this script often runs from a non-interactive
# shell whose PATH omits ~/.bun/bin, and the schedule itself runs from cron or
# launchd with a bare PATH. Missing omp here is not cosmetic — the schedule
# loses its `omp stats --json` refresh, omp's own SQLite stops advancing and
# the machine quietly stops reporting omp usage while other harnesses flow.
find_omp() {
	for candidate in "$(command -v omp 2>/dev/null || true)" "$HOME/.bun/bin/omp" "$HOME/.local/bin/omp" /opt/homebrew/bin/omp /usr/local/bin/omp; do
		[ -n "$candidate" ] && [ -x "$candidate" ] && { printf '%s' "$candidate"; return 0; }
	done
	return 1
}
OMP_BIN="$(find_omp || true)"
if [ -n "$OMP_BIN" ]; then
	say "omp: $OMP_BIN (schedule refreshes ~/.omp/stats.db before each push)"
elif [ -f "$HOME/.omp/stats.db" ]; then
	say "warning: omp data in ~/.omp/stats.db but no omp binary found - the schedule cannot refresh it,"
	say "         so omp usage will only reach the server when you run 'omp stats --json' yourself"
fi

# ---- first push -----------------------------------------------------------
# Finish the initial attempt before enabling recurring pushes. A failed push
# keeps its acknowledged cursors and is retried by the schedule.
[ -n "$OMP_BIN" ] && "$OMP_BIN" stats --json >/dev/null 2>&1 || true
say "pushing (a first backfill paces itself through the server's rate limit)"
if ! "$BUN" "$LAUNCHER" push; then
	say "warning: the first push did not complete; the scheduled collector will retry"
fi
"$BUN" "$AGENT" status

# ---- schedule -------------------------------------------------------------
# Stable launcher for both cron and launchd. Persist custom config directories
# and quote executable/data paths; neither scheduler inherits this shell's env.
shell_quote() {
	printf "'"
	printf '%s' "$1" | sed "s/'/'\\\\''/g"
	printf "'"
}
WRAP="$CFG_DIR/push.sh"
{
	printf '#!/bin/sh\nexport OMPSTATS_DIR='
	shell_quote "$CFG_DIR"
	printf '\n'
	if [ -n "$OMP_BIN" ]; then
		printf 'DRY_RUN=0\nfor arg in "$@"; do [ "$arg" = "--dry-run" ] && DRY_RUN=1; done\n'
		printf 'if [ "$DRY_RUN" = 0 ]; then '
		shell_quote "$OMP_BIN"
		printf ' stats --json >/dev/null 2>&1 || true; fi\n'
	fi
	printf 'exec '
	shell_quote "$BUN"
	printf ' '
	shell_quote "$LAUNCHER"
	printf ' push "$@"\n'
} >"$WRAP.new.$$"
chmod 700 "$WRAP.new.$$"
mv "$WRAP.new.$$" "$WRAP"
RUN="$(shell_quote "$WRAP") --quiet >> $(shell_quote "$CFG_DIR/push.log") 2>&1"

without_ompstats_cron() {
	# Also recognize the old direct invocation when both paths were customized.
	(crontab -l 2>/dev/null || true) | grep -vE "# ompstats([[:space:]]|$)|/\.ompstats/push[.]sh([\"'[:space:]]|$)|packages/agent/src/index[.]ts[\"']?[[:space:]]+push" || true
}

systemd_timer_exists() {
	[ -f "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/ompstats-push.timer" ] && return 0
	command -v systemctl >/dev/null 2>&1 || return 1
	[ "$(systemctl --user show --property=LoadState --value ompstats-push.timer 2>/dev/null || true)" = loaded ]
}


schedule_systemd() {
	command -v systemctl >/dev/null 2>&1 || die "systemd user timer exists or cron is unavailable, but systemctl is not installed"
	systemctl --user show-environment >/dev/null 2>&1 || die "systemd user manager is unavailable; cannot install ompstats-push.timer"
	UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
	mkdir -p "$UNIT_DIR"
	systemd_quote() {
		value="$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/%/%%/g; s/\$/$$/g')"
		printf '"%s"' "$value"
	}
	EXEC_START="$(systemd_quote "$WRAP") --quiet"
	SERVICE="$UNIT_DIR/ompstats-push.service"
	TIMER="$UNIT_DIR/ompstats-push.timer"
	cat >"$SERVICE.new.$$" <<UNIT_EOF
[Unit]
Description=ompstats update and push
[Service]
Type=oneshot
ExecStart=$EXEC_START
UNIT_EOF
	cat >"$TIMER.new.$$" <<UNIT_EOF
[Unit]
Description=ompstats push every 15min
[Timer]
OnCalendar=*:0/15
Persistent=true
[Install]
WantedBy=timers.target
UNIT_EOF
	mv "$SERVICE.new.$$" "$SERVICE"
	mv "$TIMER.new.$$" "$TIMER"
	systemctl --user daemon-reload || die "systemd user daemon-reload failed"
	systemctl --user enable ompstats-push.timer || die "could not enable ompstats-push.timer"
	systemctl --user restart ompstats-push.timer || die "could not restart ompstats-push.timer"
	say "schedule: systemd user timer ompstats-push.timer, every 15 min"
}

schedule_cron() {
	command -v crontab >/dev/null 2>&1 || die "no crontab on this machine - schedule '$RUN' yourself"
	# Drop any previous ompstats line, then append one: re-runs never stack up.
	CLEAN_CRON="$(without_ompstats_cron)"
	(printf '%s\n' "$CLEAN_CRON"; printf '*/15 * * * * %s # ompstats\n' "$RUN" | sed 's/%/\\%/g') | crontab -
	say "schedule: crontab, every 15 min"
}

case "$(uname -s)" in
Darwin)
	PLIST="$HOME/Library/LaunchAgents/com.ompstats.push.plist"
	mkdir -p "$HOME/Library/LaunchAgents" "$CFG_DIR"
	xml_escape() { printf '%s' "$1" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g; s/"/\&quot;/g'; }
	WRAP_XML="$(xml_escape "$WRAP")"
	LOG_XML="$(xml_escape "$CFG_DIR/push.log")"
	ERROR_LOG_XML="$(xml_escape "$CFG_DIR/push-error.log")"
	cat >"$PLIST.new.$$" <<PLIST_EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
	<key>Label</key><string>com.ompstats.push</string>
	<key>ProgramArguments</key><array><string>$WRAP_XML</string><string>--quiet</string></array>
	<key>StartInterval</key><integer>900</integer>
	<key>RunAtLoad</key><true/>
	<key>StandardOutPath</key><string>$LOG_XML</string>
	<key>StandardErrorPath</key><string>$ERROR_LOG_XML</string>
</dict></plist>
PLIST_EOF
	mv "$PLIST.new.$$" "$PLIST"
	launchctl bootout "gui/$(id -u)/com.ompstats.push" >/dev/null 2>&1 || true
	launchctl bootstrap "gui/$(id -u)" "$PLIST" >/dev/null 2>&1 ||
		launchctl load "$PLIST" >/dev/null 2>&1 || true
	# A loaded LaunchAgent is not yet a schedule. When the gui domain is in
	# on-demand-only mode (launchd logs "pending spawn, domain in
	# on-demand-only mode"), RunAtLoad and StartInterval never fire, the job
	# sits at `runs = 0` and the machine silently stops reporting. RunAtLoad
	# spawns at once, so a job that has not run by now never will: fall back
	# to cron rather than leave a dead schedule behind.
	sleep 3
	if launchctl print "gui/$(id -u)/com.ompstats.push" 2>/dev/null | grep -qE '^[[:space:]]*runs = [1-9]'; then
		if command -v crontab >/dev/null 2>&1; then
			CLEAN_CRON="$(without_ompstats_cron)"
			printf '%s\n' "$CLEAN_CRON" | crontab -
		fi
		say "schedule: LaunchAgent com.ompstats.push, every 15 min"
	else
		say "LaunchAgent never fired (launchd gui domain is on-demand-only) - using cron instead"
		launchctl bootout "gui/$(id -u)/com.ompstats.push" >/dev/null 2>&1 || true
		rm -f "$PLIST"
		schedule_cron
	fi
	;;
Linux)
	if systemd_timer_exists || ! command -v crontab >/dev/null 2>&1; then
		schedule_systemd
	else
		schedule_cron
	fi
	;;
*)
	schedule_cron
esac

# ---- optional native menu -------------------------------------------------
# Explicit only: the source choice controls local account collection separately,
# and the menu never enables subscription sharing. Existing login preference is
# preserved on updates unless OMPSTATS_MENUBAR=on|off is supplied.
if [ "$(uname -s)" = Darwin ]; then
	MENUBAR_PLIST="$HOME/Library/LaunchAgents/com.ompstats.menubar.plist"
	if [ -z "$MENUBAR_CHOICE" ] && [ ! -f "$MENUBAR_PLIST" ]; then
		if { exec 3<>/dev/tty; } 2>/dev/null; then
			answer=""
			if printf 'Enable the optional ompstats subscription menu at login? [y/N] ' >&3; then
				if IFS= read -r answer <&3; then :; fi
			fi
			exec 3>&-
			case "$answer" in
				[Yy]|[Yy][Ee][Ss]) MENUBAR_CHOICE=on ;;
			esac
		fi
	fi
	case "$MENUBAR_CHOICE" in
		on) "$BUN" "$AGENT" subscriptions menubar enable ;;
		off) "$BUN" "$AGENT" subscriptions menubar disable ;;
		"") say "menu login preference unchanged (disabled unless explicitly enabled)" ;;
	esac
fi

say "done - automatic updates and statistics pushes run every 15 min"
