#!/usr/bin/env bash
#
# GetMCP one-line installer.
#
#   curl -fsSL https://get.getmcp.com | bash
#
# Downloads the standalone build, checks the server can actually run it, puts
# the files in place with the right permissions, and hands back the URL of the
# setup screen. Everything after that happens in the browser: database, then
# administrator account.
#
# Deliberately needs nothing but curl (or wget), tar or unzip, and PHP. No
# Composer, no Node, no git — the release archive already contains the vendor
# directory and the built admin interface, because the people installing this
# are frequently on hosting where none of those exist.
#
# Options (also settable as environment variables):
#   --dir=PATH        where to install          (GETMCP_DIR)
#   --url=URL         public address of the app (GETMCP_URL)
#   --docroot=PATH    web root, when it cannot contain the app itself
#   --version=X.Y.Z   pin a release             (GETMCP_VERSION)
#   --serve[=PORT]    start PHP's built-in server when finished
#   --force           install into a non-empty directory
#   --yes             never prompt
#
set -euo pipefail

readonly GETMCP_CDN="${GETMCP_CDN:-https://get.getmcp.com}"
readonly MIN_PHP="8.3"
REQUIRED_EXT="sodium pdo curl mbstring json openssl"

DIR="${GETMCP_DIR:-}"
URL="${GETMCP_URL:-}"
DOCROOT=""
VERSION="${GETMCP_VERSION:-latest}"
SERVE=""
FORCE=""
ASSUME_YES=""
PHP_BIN=""
TMP=""

# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------

if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
	C_DIM=$'\033[2m'; C_RED=$'\033[31m'; C_GREEN=$'\033[32m'
	C_YELLOW=$'\033[33m'; C_BOLD=$'\033[1m'; C_OFF=$'\033[0m'
else
	C_DIM=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_BOLD=""; C_OFF=""
fi

say()  { printf '%s\n' "$*"; }
step() { printf '  %s→%s %s\n' "$C_DIM" "$C_OFF" "$*"; }
ok()   { printf '  %s✓%s %s\n' "$C_GREEN" "$C_OFF" "$*"; }
warn() { printf '  %s!%s %s\n' "$C_YELLOW" "$C_OFF" "$*"; }

die() {
	printf '\n  %s✗ %s%s\n' "$C_RED" "$*" "$C_OFF" >&2
	printf '\n  Stuck? %s\n\n' "https://getmcp.com/docs/install" >&2
	exit 1
}

cleanup() { [ -n "$TMP" ] && [ -d "$TMP" ] && rm -rf "$TMP"; }
trap cleanup EXIT

# Piped through `curl | bash`, stdin is the script itself, so anything
# interactive has to talk to the terminal directly.
ask() {
	local prompt="$1" default="${2:-}" reply=""

	if [ -n "$ASSUME_YES" ] || [ ! -r /dev/tty ]; then
		printf '%s' "$default"
		return
	fi

	printf '  %s ' "$prompt" > /dev/tty
	[ -n "$default" ] && printf '[%s] ' "$default" > /dev/tty
	IFS= read -r reply < /dev/tty || reply=""
	printf '%s' "${reply:-$default}"
}

confirm() {
	local reply
	reply="$(ask "$1 [y/N]" "n")"
	case "$reply" in [yY]*) return 0 ;; *) return 1 ;; esac
}

# ---------------------------------------------------------------------------
# Arguments
# ---------------------------------------------------------------------------

for arg in "$@"; do
	case "$arg" in
		--dir=*)      DIR="${arg#*=}" ;;
		--url=*)      URL="${arg#*=}" ;;
		--docroot=*)  DOCROOT="${arg#*=}" ;;
		--version=*)  VERSION="${arg#*=}" ;;
		--serve)      SERVE="8000" ;;
		--serve=*)    SERVE="${arg#*=}" ;;
		--force)      FORCE="1" ;;
		--yes|-y)     ASSUME_YES="1" ;;
		--help|-h)
			sed -n '3,28p' "$0" | sed 's/^# \{0,1\}//'
			exit 0 ;;
		*) die "Unknown option: $arg" ;;
	esac
done

# ---------------------------------------------------------------------------
# Requirements
# ---------------------------------------------------------------------------

find_php() {
	# Managed hosts often leave an ancient `php` first on PATH while shipping a
	# current one under a versioned name, so the explicit paths are tried first.
	local candidates=(
		"${GETMCP_PHP:-}"
		/opt/cpanel/ea-php84/root/usr/bin/php
		/opt/cpanel/ea-php83/root/usr/bin/php
		/opt/plesk/php/8.4/bin/php
		/opt/plesk/php/8.3/bin/php
		/usr/local/bin/php84 /usr/local/bin/php83
		php8.4 php8.3 php
	)

	local best="" best_ver="0"
	for c in "${candidates[@]}"; do
		[ -z "$c" ] && continue
		command -v "$c" >/dev/null 2>&1 || continue

		local ver
		ver="$("$c" -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;' 2>/dev/null)" || continue

		# Anything that is not exactly major.minor is not a PHP binary answering
		# the question — a wrapper script, a shell builtin, or a PHP that printed
		# a deprecation notice to stdout first. Skip it rather than feed the
		# arithmetic comparison a string it will error on.
		case "$ver" in
			[0-9]*.[0-9]*) : ;;
			*) continue ;;
		esac
		printf '%s' "$ver" | grep -qE '^[0-9]+\.[0-9]+$' || continue

		if php_at_least "$ver" "$MIN_PHP"; then
			# First candidate that satisfies the floor wins; the list is ordered.
			printf '%s' "$c"
			return 0
		fi

		if php_at_least "$ver" "$best_ver"; then best="$c"; best_ver="$ver"; fi
	done

	[ -n "$best" ] && printf '%s|%s' "$best" "$best_ver"
	return 1
}

php_at_least() {
	# $1 >= $2, comparing major.minor numerically
	local a_maj="${1%%.*}" a_min="${1#*.}" b_maj="${2%%.*}" b_min="${2#*.}"
	a_min="${a_min%%.*}"; b_min="${b_min%%.*}"
	[ "$a_maj" -gt "$b_maj" ] && return 0
	[ "$a_maj" -lt "$b_maj" ] && return 1
	[ "$a_min" -ge "$b_min" ]
}

check_requirements() {
	step "Checking this server"

	local found
	if ! found="$(find_php)"; then
		if [ -n "$found" ]; then
			die "GetMCP needs PHP $MIN_PHP or newer; the best available is ${found#*|}.
     Most control panels let you pick the PHP version per site.
     Already have a newer one? Point at it: GETMCP_PHP=/path/to/php"
		fi
		die "No PHP found on this machine.
     GetMCP is a PHP application and needs PHP $MIN_PHP or newer."
	fi
	PHP_BIN="$found"

	local ver
	ver="$("$PHP_BIN" -r 'echo PHP_VERSION;')"
	ok "PHP $ver ($PHP_BIN)"

	local missing=()
	for ext in $REQUIRED_EXT; do
		"$PHP_BIN" -r "exit(extension_loaded('$ext') ? 0 : 1);" || missing+=("$ext")
	done

	if [ ${#missing[@]} -gt 0 ]; then
		# sodium is the one that actually stops the product working rather than
		# merely inconveniencing it, so it gets said plainly.
		local note=""
		case " ${missing[*]} " in
			*" sodium "*) note="
     sodium is what encrypts stored API credentials — without it GetMCP
     cannot save a credential at all, so this is not optional." ;;
		esac
		die "PHP is missing: ${missing[*]}
     Enable ${missing[*]} for this PHP version and run this again.$note"
	fi
	ok "Extensions: $REQUIRED_EXT"

	command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 \
		|| die "Neither curl nor wget is available to download the release."

	command -v unzip >/dev/null 2>&1 || command -v tar >/dev/null 2>&1 \
		|| die "Neither unzip nor tar is available to unpack the release."
}

# ---------------------------------------------------------------------------
# Download
# ---------------------------------------------------------------------------

fetch() {
	local url="$1" out="$2"
	if command -v curl >/dev/null 2>&1; then
		curl -fsSL --retry 3 --retry-delay 1 -o "$out" "$url"
	else
		wget -q -t 3 -O "$out" "$url"
	fi
}

download_release() {
	TMP="$(mktemp -d "${TMPDIR:-/tmp}/getmcp.XXXXXX")"

	local base="$GETMCP_CDN/release/$VERSION"
	step "Downloading GetMCP ($VERSION)"

	fetch "$base/getmcp-standalone.zip" "$TMP/getmcp.zip" \
		|| die "Could not download the release from $base
     Check the machine can reach the internet, then try again."

	# Checksum is advisory rather than fatal: a mirror or proxy that fails to
	# serve the .sha256 should not block an install, but a file that IS served
	# and does not match must stop everything.
	if fetch "$base/getmcp-standalone.zip.sha256" "$TMP/getmcp.zip.sha256" 2>/dev/null; then
		local expected actual
		expected="$(tr -d '[:space:]' < "$TMP/getmcp.zip.sha256" | cut -d'=' -f2- | tail -c 65)"
		if command -v sha256sum >/dev/null 2>&1; then
			actual="$(sha256sum "$TMP/getmcp.zip" | cut -d' ' -f1)"
		elif command -v shasum >/dev/null 2>&1; then
			actual="$(shasum -a 256 "$TMP/getmcp.zip" | cut -d' ' -f1)"
		fi

		if [ -n "${actual:-}" ]; then
			[ "$expected" = "$actual" ] || die "The download does not match its published checksum.
     Refusing to install it. Try again; if it persists, tell us at getmcp.com/contact."
			ok "Checksum verified"
		fi
	else
		warn "Checksum unavailable — continuing without verifying"
	fi

	step "Unpacking"
	if command -v unzip >/dev/null 2>&1; then
		unzip -q "$TMP/getmcp.zip" -d "$TMP/x"
	else
		mkdir -p "$TMP/x" && tar -xf "$TMP/getmcp.zip" -C "$TMP/x"
	fi

	[ -f "$TMP/x/getmcp/artisan" ] || die "The downloaded archive is not what was expected."
}

# ---------------------------------------------------------------------------
# Install
# ---------------------------------------------------------------------------

choose_dir() {
	if [ -z "$DIR" ]; then
		DIR="$(ask "Install GetMCP where?" "$PWD/getmcp")"
	fi

	# Expand a leading ~ that arrived as a literal.
	case "$DIR" in "~/"*) DIR="$HOME/${DIR#\~/}" ;; esac

	if [ -e "$DIR" ] && [ -n "$(ls -A "$DIR" 2>/dev/null)" ]; then
		if [ -f "$DIR/.env" ] || [ -f "$DIR/storage/installed.lock" ]; then
			die "$DIR already contains a GetMCP install.
     To update an existing install, follow https://getmcp.com/docs/updating
     — it keeps your .env, database and storage."
		fi
		[ -n "$FORCE" ] || die "$DIR is not empty.
     Pick an empty directory, or pass --force to install here anyway."
	fi

	mkdir -p "$DIR" || die "Cannot create $DIR"
	[ -w "$DIR" ] || die "$DIR is not writable by $(whoami)."
}

install_files() {
	step "Installing into $DIR"

	# Copy rather than move: /tmp is frequently a different filesystem, and mv
	# across filesystems is not atomic anyway.
	( cd "$TMP/x/getmcp" && tar -cf - . ) | ( cd "$DIR" && tar -xf - )

	mkdir -p "$DIR/storage/framework/cache/data" \
	         "$DIR/storage/framework/sessions" \
	         "$DIR/storage/framework/views" \
	         "$DIR/storage/logs" \
	         "$DIR/bootstrap/cache" \
	         "$DIR/database"

	chmod -R 755 "$DIR/storage" "$DIR/bootstrap/cache" "$DIR/database" 2>/dev/null || true
	ok "Files in place"
}

configure() {
	step "Configuring"

	[ -f "$DIR/.env" ] || cp "$DIR/.env.example" "$DIR/.env"
	chmod 644 "$DIR/.env"

	# The app can generate its own key on first request, but doing it here means
	# a failure is visible now rather than as a blank page later.
	local key
	key="$("$PHP_BIN" -r 'echo "base64:".base64_encode(random_bytes(32));')"
	set_env APP_KEY "$key"
	set_env APP_ENV production
	set_env APP_DEBUG false
	# File sessions need no database, which the installer does not have yet.
	set_env SESSION_DRIVER file

	if [ -z "$URL" ]; then
		URL="$(ask "Public address of this install?" "http://localhost:${SERVE:-8000}")"
	fi
	URL="${URL%/}"
	set_env APP_URL "$URL"

	ok "Configured for $URL"
}

# Replace or append a key in .env, quoting only when the value needs it.
set_env() {
	local key="$1" value="$2" file="$DIR/.env"

	case "$value" in
		*[[:space:]\#\"\']*) value="\"$value\"" ;;
	esac

	if grep -qE "^${key}=" "$file" 2>/dev/null; then
		# A literal-safe in-place edit without relying on sed -i, which differs
		# between GNU and BSD.
		local tmp="$file.tmp.$$"
		awk -v k="$key" -v v="$value" '
			$0 ~ "^" k "=" { print k "=" v; found = 1; next }
			{ print }
			END { if (!found) print k "=" v }
		' "$file" > "$tmp" && mv "$tmp" "$file"
	else
		printf '%s=%s\n' "$key" "$value" >> "$file"
	fi
}

# ---------------------------------------------------------------------------
# Document root
# ---------------------------------------------------------------------------

# The single most common way a PHP app install goes wrong: the web server is
# pointed at the application folder instead of its public/ directory, which
# leaves .env — database password and APP_KEY — fetchable over HTTP.
arrange_docroot() {
	if [ -z "$DOCROOT" ]; then
		suggest_docroot
		return
	fi

	case "$DOCROOT" in "~/"*) DOCROOT="$HOME/${DOCROOT#\~/}" ;; esac

	[ -d "$DOCROOT" ] || die "Document root $DOCROOT does not exist."
	[ -w "$DOCROOT" ] || die "Document root $DOCROOT is not writable."

	if [ -n "$(ls -A "$DOCROOT" 2>/dev/null)" ] && [ -z "$FORCE" ]; then
		confirm "$DOCROOT is not empty. Copy GetMCP's public files into it anyway?" \
			|| die "Stopped. Nothing has been written to $DOCROOT."
	fi

	step "Publishing to $DOCROOT"
	( cd "$DIR/public" && tar -cf - . ) | ( cd "$DOCROOT" && tar -xf - )

	# One line decides where the app lives; see the comment in public/index.php.
	local rel
	rel="$(python_relpath "$DIR" "$DOCROOT")"

	local tmp="$DOCROOT/index.php.tmp.$$"
	awk -v base="$rel" '
		$0 ~ /^\$getmcpBase = / { print "$getmcpBase = __DIR__." base ";"; next }
		{ print }
	' "$DOCROOT/index.php" > "$tmp" && mv "$tmp" "$DOCROOT/index.php"

	grep -q "^\$getmcpBase = __DIR__.$rel;" "$DOCROOT/index.php" \
		|| die "Could not point $DOCROOT/index.php at $DIR — edit \$getmcpBase there by hand."

	ok "Document root serves GetMCP; the application stays outside it"
}

# Relative path from $2 to $1, as a PHP string concatenation suffix.
python_relpath() {
	local target="$1" from="$2"
	if command -v python3 >/dev/null 2>&1; then
		python3 -c 'import os,sys; print("\x27/"+os.path.relpath(sys.argv[1], sys.argv[2])+"\x27")' "$target" "$from"
	else
		# Good enough for the common layout: app and docroot as siblings.
		printf "'/../%s'" "$(basename "$target")"
	fi
}

suggest_docroot() {
	local guess=""
	for d in "$HOME/public_html" "$HOME/www" /var/www/html; do
		[ -d "$d" ] && { guess="$d"; break; }
	done

	[ -z "$guess" ] && return 0
	[ "${DIR#"$guess"}" != "$DIR" ] && return 0   # already inside the docroot

	say ""
	warn "Found a web root at $guess"
	say "     GetMCP must be served from its public/ folder, never its root —"
	say "     otherwise .env (database password, APP_KEY) is readable over HTTP."
	say "     Point the site at ${C_BOLD}$DIR/public${C_OFF}, or re-run with:"
	say "       ${C_DIM}--docroot=$guess${C_OFF}"
}

# ---------------------------------------------------------------------------
# Finish
# ---------------------------------------------------------------------------

finish() {
	local install_url="$URL/install"

	say ""
	say "  ${C_GREEN}${C_BOLD}GetMCP is installed.${C_OFF}"
	say ""
	say "  Finish setup in your browser:"
	say "    ${C_BOLD}$install_url${C_OFF}"
	say ""
	say "  Two screens: your database, then your administrator account."
	say "  The installer closes itself afterwards."
	say ""
	say "  ${C_DIM}Add this so scheduled jobs run:${C_OFF}"
	say "    ${C_DIM}* * * * * cd $DIR && $PHP_BIN artisan schedule:run >> /dev/null 2>&1${C_OFF}"
	say ""

	if [ -n "$SERVE" ]; then
		say "  Starting a local server on port $SERVE — press Ctrl+C to stop."
		say ""
		exec "$PHP_BIN" -S "127.0.0.1:$SERVE" -t "$DIR/public" "$DIR/public/index.php"
	fi
}

# ---------------------------------------------------------------------------

main() {
	say ""
	say "  ${C_BOLD}GetMCP${C_OFF} ${C_DIM}— turn any API into an MCP server${C_OFF}"
	say ""

	check_requirements
	choose_dir
	download_release
	install_files
	configure
	arrange_docroot
	finish
}

main "$@"
