#!/bin/sh
set -eu

# Installs or upgrades the localport CLI on Linux and macOS.
#
#   curl -fsSL https://localport.io/install.sh | sh
#
# An existing install is upgraded in place at the path the shell resolves, so
# one binary is on disk. A binary owned by a package manager is refused.
#
# Overrides (env):
#   LOCALPORT_VERSION       install a specific tag instead of the latest stable
#   LOCALPORT_INSTALL_DIR   target directory, overriding an in-place upgrade
#   LOCALPORT_FORCE         reinstall even when the installed binary is current

REPO="localport/agent"
BIN_NAME="localport"
RELEASES="https://github.com/${REPO}/releases"

# Set as the script proceeds; the exit trap removes only what exists.
TMP=""
STAGED=""
SUDO=""
OWNER=""
OWNER_CMD=""

info() { printf '>>> %s\n' "$*" >&2; }
warn() { printf 'warning: %s\n' "$*" >&2; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
has() { command -v "$1" >/dev/null 2>&1; }

cleanup() {
	if [ -n "$TMP" ]; then
		rm -rf "$TMP"
	fi
	# The staged file is in the install directory and can need the privileges
	# that created it. -n so an expired sudo timestamp cannot block the exit.
	if [ -n "$STAGED" ]; then
		${SUDO:+sudo -n} rm -f "$STAGED" 2>/dev/null || true
	fi
}

detect_os() {
	case "$(uname -s)" in
	Linux) echo linux ;;
	Darwin) echo darwin ;;
	MINGW* | MSYS* | CYGWIN*)
		die "this installs the Linux and macOS builds; on Windows run: irm https://localport.io/install.ps1 | iex"
		;;
	*) die "unsupported OS: $(uname -s)" ;;
	esac
}

detect_arch() {
	da_arch="$(uname -m)"
	# uname -m reports x86_64 under Rosetta 2 on Apple Silicon. The kernel flag
	# is authoritative.
	if [ "$da_arch" = x86_64 ] && [ "$(uname -s)" = Darwin ]; then
		if [ "$(sysctl -n hw.optional.arm64 2>/dev/null || echo 0)" = 1 ]; then
			da_arch=arm64
		fi
	fi
	case "$da_arch" in
	x86_64 | amd64) echo amd64 ;;
	aarch64 | arm64) echo arm64 ;;
	*) die "unsupported architecture: $(uname -m)" ;;
	esac
}

fetch() {
	# fetch <url> <dest>
	if has curl; then
		# --proto '=https' refuses a redirect off HTTPS. --tlsv1.2 sets the floor.
		curl --proto '=https' --tlsv1.2 -fsSL --retry 3 -o "$2" "$1"
	elif has wget; then
		# wget defaults to 20 tries; --tries=4 matches curl's --retry 3 above.
		if wget --help 2>&1 | grep -q -- --https-only; then
			wget -q --tries=4 --https-only -O "$2" "$1"
		else
			wget -q --tries=4 -O "$2" "$1"
		fi
	else
		die "need curl or wget"
	fi
}

resolve_latest_tag() {
	# The tag appears only in the /latest redirect; asset URLs redirect to a
	# signed blob that omits it. An empty result prints "latest".
	if has curl; then
		curl --proto '=https' --tlsv1.2 -fsSLI -o /dev/null -w '%{url_effective}' \
			"${RELEASES}/latest" 2>/dev/null |
			sed -n 's|.*/releases/tag/\([^/]*\)$|\1|p'
	elif has wget; then
		wget -q --spider --max-redirect=0 -S "${RELEASES}/latest" 2>&1 |
			sed -n 's|.*[Ll]ocation: .*/releases/tag/\([^/ ]*\).*|\1|p' |
			head -n 1
	fi
}

sha256() {
	# tolower in awk, which is already running, to match expected_sum without
	# depending on a second tool.
	if has sha256sum; then
		sh_sum="$(sha256sum "$1" | awk '{print tolower($1)}')"
	elif has shasum; then
		sh_sum="$(shasum -a 256 "$1" | awk '{print tolower($1)}')"
	else
		die "need sha256sum or shasum to verify the download"
	fi
	# A pipeline returns awk's status, so a failed hash tool yields an empty
	# string rather than an error.
	[ -n "$sh_sum" ] || die "could not hash ${1}"
	printf '%s\n' "$sh_sum"
}

expected_sum() {
	# expected_sum <checksums file> <asset name>
	# Coreutils writes "<hash>  <name>", or "<hash> *<name>" in binary mode. The
	# CR strip and tolower keep this equal to install.ps1's comparison.
	awk -v n="$2" '
		{ sub(/\r$/, "") }
		$2 == n || $2 == "*" n { print tolower($1); exit }
	' "$1"
}

real_path() {
	# real_path <path>; follows symlinks. readlink -f is absent on macOS.
	rp_target="$1"
	rp_hops=0
	while [ -L "$rp_target" ] && [ "$rp_hops" -lt 40 ]; do
		rp_link="$(readlink "$rp_target")"
		case "$rp_link" in
		/*) rp_target="$rp_link" ;;
		*) rp_target="$(dirname "$rp_target")/${rp_link}" ;;
		esac
		rp_hops=$((rp_hops + 1))
	done
	# Assigned before printing: inside the printf argument a failed cd yields an
	# empty directory and a path naming a different file.
	rp_dir="$(cd "$(dirname "$rp_target")" 2>/dev/null && pwd -P)" ||
		die "cannot resolve ${1}"
	printf '%s/%s\n' "$rp_dir" "$(basename "$rp_target")"
}

resolved_command() {
	# Absolute path the shell would run for BIN_NAME, or empty. A function or
	# alias resolves to a name, not a path, so non-absolute results are dropped.
	rc_path="$(command -v "$BIN_NAME" 2>/dev/null)" || rc_path=""
	case "$rc_path" in
	/*) real_path "$rc_path" ;;
	esac
}

detect_owner() {
	# detect_owner <real path>; sets OWNER and OWNER_CMD when a package manager
	# owns the binary.
	OWNER=""
	OWNER_CMD=""
	case "$1" in
	*/Cellar/*)
		OWNER="Homebrew"
		OWNER_CMD="brew upgrade localport"
		return 0
		;;
	/nix/store/*)
		OWNER="Nix"
		OWNER_CMD="nix profile upgrade localport"
		return 0
		;;
	/snap/*)
		OWNER="Snap"
		OWNER_CMD="snap refresh localport"
		return 0
		;;
	esac
	if has dpkg && dpkg -S "$1" >/dev/null 2>&1; then
		OWNER="dpkg"
		OWNER_CMD="apt upgrade localport"
	elif has rpm && rpm -qf "$1" >/dev/null 2>&1; then
		OWNER="rpm"
		OWNER_CMD="dnf upgrade localport"
	elif has pacman && pacman -Qo "$1" >/dev/null 2>&1; then
		OWNER="pacman"
		OWNER_CMD="pacman -Syu localport"
	elif has apk && apk info --who-owns "$1" >/dev/null 2>&1; then
		OWNER="apk"
		OWNER_CMD="apk upgrade localport"
	fi
	return 0
}

installed_version() {
	# installed_version <path>; empty when the binary will not run. </dev/null:
	# stdin is the script itself under `curl | sh`.
	"$1" version </dev/null 2>/dev/null | awk 'NR == 1 { print $2 }'
}

writable_dir() {
	if [ -d "$1" ]; then
		[ -w "$1" ]
	else
		mkdir -p "$1" 2>/dev/null
	fi
}

can_write_dir() {
	# Tests writability without creating anything, so the target can be chosen
	# before the up-to-date check. Walks up to the deepest existing ancestor.
	cw_dir="$1"
	while [ ! -d "$cw_dir" ] && [ "$cw_dir" != / ] && [ "$cw_dir" != . ]; do
		cw_dir="$(dirname "$cw_dir")"
	done
	[ -w "$cw_dir" ]
}

install_atomic() {
	# install_atomic <source> <target>
	# Stages inside the target directory so the final move is a rename: atomic,
	# and valid at every instant while the binary is running.
	#
	# mktemp, not a PID-derived name: a predictable staged path in a shared or
	# root-owned directory can be pre-created as a symlink, and cp writes through
	# a symlink while chmod applies to its target. mktemp uses O_EXCL.
	STAGED="$(${SUDO} mktemp "$(dirname "$2")/.${BIN_NAME}.XXXXXX")" ||
		die "cannot create a staging file in $(dirname "$2")"
	${SUDO} cp "$1" "$STAGED"
	${SUDO} chmod 0755 "$STAGED"
	${SUDO} mv -f "$STAGED" "$2"
	STAGED=""
}

main() {
	trap cleanup EXIT INT TERM

	# Assigned separately: inside one word the status of an earlier failed
	# substitution is lost and the asset name is built malformed.
	os="$(detect_os)"
	arch="$(detect_arch)"
	asset="${BIN_NAME}-${os}-${arch}"

	# Checked before fetching, so a machine with no hash tool fails immediately
	# rather than after downloading the binary.
	has sha256sum || has shasum ||
		die "need sha256sum or shasum to verify the download"

	# The binary this run replaces, with symlinks resolved so an upgrade rewrites
	# the real file and not a link to it.
	existing=""
	current=""
	if [ -n "${LOCALPORT_INSTALL_DIR:-}" ]; then
		# An explicit directory decides the target. Trailing slashes are stripped
		# so printed paths match what was typed.
		install_dir="${LOCALPORT_INSTALL_DIR%"${LOCALPORT_INSTALL_DIR##*[!/]}"}"
		# A leading dash is read as an option by dirname, mkdir and cp.
		case "$install_dir" in
		-*) install_dir="./${install_dir}" ;;
		esac
		target="${install_dir:-/}/${BIN_NAME}"
	else
		existing="$(resolved_command)"
		if [ -z "$existing" ]; then
			# Directories this installer writes to, in preference order. Probed
			# only when PATH resolves nothing, so an install placed by an earlier
			# run is upgraded rather than duplicated.
			set -- "/usr/local/bin/${BIN_NAME}"
			if [ -n "${HOME:-}" ]; then
				set -- "$@" "${HOME}/.local/bin/${BIN_NAME}"
			fi
			for candidate in "$@"; do
				if [ -e "$candidate" ]; then
					existing="$(real_path "$candidate")"
					break
				fi
			done
		fi
		if [ -n "$existing" ]; then
			target="$existing"
		else
			# Decided here, not at write time, so a second run finds the first
			# run's install. can_write_dir creates nothing.
			target="/usr/local/bin/${BIN_NAME}"
			if ! can_write_dir /usr/local/bin && ! has sudo && [ -n "${HOME:-}" ]; then
				target="${HOME}/.local/bin/${BIN_NAME}"
			fi
		fi
	fi

	# PATH resolved nothing, but the target can still hold an install PATH does
	# not reach; without this that machine re-downloads on every run.
	if [ -z "$existing" ] && [ -e "$target" ]; then
		existing="$(real_path "$target")"
		# The resolved path becomes the target: a directory given as /opt/a/../b,
		# or a symlink at the target, otherwise leaves the two naming one file by
		# two strings and the $target = $existing tests below fail.
		target="$existing"
	fi

	# Runs on whatever was found, by either route. Homebrew on Intel macOS
	# symlinks into /usr/local/bin, this script's own default directory.
	if [ -n "$existing" ] && [ -z "${LOCALPORT_INSTALL_DIR:-}" ]; then
		detect_owner "$existing"
		if [ -n "$OWNER" ]; then
			printf 'error: %s is managed by %s.\n' "$existing" "$OWNER" >&2
			printf '  Upgrade it with:\n      %s\n' "$OWNER_CMD" >&2
			printf '  Or install an unmanaged copy elsewhere:\n' >&2
			printf '      LOCALPORT_INSTALL_DIR="$HOME/.local/bin" curl -fsSL https://localport.io/install.sh | sh\n' >&2
			exit 1
		fi
	fi

	# A directory, socket or device at the binary's path is not an install.
	if [ -n "$existing" ] && [ ! -f "$existing" ]; then
		die "${existing} exists but is not a regular file; remove it and re-run"
	fi

	if [ -n "$existing" ]; then
		current="$(installed_version "$existing")"
		if [ -n "$current" ]; then
			info "found ${BIN_NAME} ${current} at ${existing}"
		else
			info "found ${BIN_NAME} at ${existing}"
		fi
	fi

	tag="${LOCALPORT_VERSION:-}"
	if [ -n "$tag" ]; then
		base="${RELEASES}/download/${tag}"
	else
		tag="$(resolve_latest_tag)" || tag=""
		base="${RELEASES}/latest/download"
	fi

	TMP="$(mktemp -d)"

	# Fetched before the binary: it carries the expected hash, which is also what
	# decides whether this machine already holds that exact build.
	fetch "${base}/checksums.txt" "${TMP}/checksums.txt" ||
		die "could not fetch checksums.txt from ${base}"
	expected="$(expected_sum "${TMP}/checksums.txt" "$asset")"
	[ -n "$expected" ] || die "no checksum listed for ${asset}"

	# -x is part of the test: matching bytes are not a working install if the
	# mode is wrong. Falling through reinstalls and sets it.
	if [ -n "$existing" ] && [ -r "$existing" ] && [ -x "$existing" ] &&
		[ -z "${LOCALPORT_FORCE:-}" ] &&
		[ "$(sha256 "$existing")" = "$expected" ]; then
		if [ -n "$current" ]; then
			info "${BIN_NAME} ${current} is already up to date"
		else
			info "${existing} is already up to date"
		fi
		info "set LOCALPORT_FORCE=1 to reinstall anyway"
		exit 0
	fi

	dir="$(dirname "$target")"

	# Privileges are acquired only here, so a run with nothing to do never
	# prompts for a password.
	if ! writable_dir "$dir"; then
		if has sudo; then
			info "writing to ${dir} (requires sudo)"
			SUDO=sudo
			${SUDO} mkdir -p "$dir" || die "cannot create ${dir}"
		else
			die "cannot write to ${dir}, and sudo is not available"
		fi
	fi

	if [ "$target" = "$existing" ] && [ -n "$current" ]; then
		info "upgrading ${current} -> ${tag:-latest}"
	elif [ "$target" = "$existing" ]; then
		info "replacing ${target} with ${tag:-latest}"
	else
		info "installing ${BIN_NAME} ${tag:-latest}"
	fi

	info "downloading ${asset}"
	fetch "${base}/${asset}" "${TMP}/${asset}" || die "download failed: ${base}/${asset}"

	info "verifying checksum"
	actual="$(sha256 "${TMP}/${asset}")"
	[ "$expected" = "$actual" ] ||
		die "checksum mismatch for ${asset} (expected ${expected}, got ${actual})"

	install_atomic "${TMP}/${asset}" "$target"

	if [ "$target" = "$existing" ]; then
		info "upgraded ${target}"
	else
		info "installed to ${target}"
	fi

	# A copy earlier on PATH is run instead of the one just installed.
	after="$(resolved_command)"
	if [ -z "$after" ]; then
		warn "${dir} is not in your PATH - add it to your shell profile"
	elif [ "$after" != "$(real_path "$target")" ]; then
		warn "${after} comes first on your PATH and will run instead of ${target}"
	fi

	printf '\n'
	"$target" version </dev/null 2>/dev/null || true

	if [ -z "$existing" ]; then
		cat <<'NEXT'

Next steps:
  1. Create a tunnel token at https://dashboard.localport.io
  2. localport http 3000 --token <token>

Docs: https://localport.io/docs
NEXT
	fi
}

# Called last: a download truncated mid-transfer defines functions and runs
# nothing.
main "$@"
