#!/usr/bin/env bash
# Install the Pi Nope dashboard for the current user. Requires Bash 3.2 or later.
set -euo pipefail
umask 077

VERSION=0.2.0-preview.3
DOWNLOAD_BASE=https://pinope.genwhy.ai/downloads
PREFIX=${HOME:?HOME is not set}/.local
REPLACE=0
TEMP_DIR= INSTALL_STAGE= LINK_STAGE= BACKUP_DIR= DEST= LINK= LINK_TARGET=
SWAPPED=0
COMMITTED=0
LINK_WAS_PRESENT=0

fail() { printf 'Pi Nope: %s\n' "$*" >&2; exit 1; }
help() {
  cat <<'HELP'
Install Pi Nope for your user account. No sudo is needed.

Usage: bash install.sh [options]
  --help                 Show this help
  --version VERSION      Release to install (default: 0.2.0-preview.3)
  --prefix DIR           Install under DIR (default: $HOME/.local)
  --download-base URL    HTTPS download root; VERSION is appended
  --replace              Replace an existing installer-managed app

Downloads: https://pinope.genwhy.ai/downloads/VERSION/
The archive must match its SHA256SUMS entry. No downloaded code runs during setup.
Supported: Linux amd64/arm64/Armv7; macOS 13+ amd64/arm64. Armv6 is not supported.

The command link is PREFIX/bin/pi-nope. App files are in PREFIX/share/pi-nope.
On Mac, that folder also contains Pi Nope.app. The CLI link runs the server
in your terminal; the .app opens the browser and runs in the background.

Quit a running Pi Nope before using --replace. Unknown existing files are
never overwritten. App settings are separate and are not changed. Shell
profiles, login services, DNS settings, and router settings are not changed.

This installs Pi Nope only. Live data needs an existing Pi-hole v6 server.
Pi-hole setup: https://docs.pi-hole.net/main/basic-install/
The preview is unsigned and not notarized. Keep system protections enabled.
HELP
}

cleanup() {
  local status=$?
  trap - EXIT
  set +e
  if [[ "$COMMITTED" == 0 ]]; then
    if [[ "$SWAPPED" == 1 ]]; then rm -rf -- "$DEST"; fi
    if [[ "$SWAPPED" == 1 && "$LINK_WAS_PRESENT" == 0 && -L "$LINK" ]]; then
      if [[ "$(readlink "$LINK")" == "$LINK_TARGET" ]]; then rm -f -- "$LINK"; fi
    fi
    if [[ -n "$BACKUP_DIR" && -d "$BACKUP_DIR/previous" ]]; then
      if ! mv -- "$BACKUP_DIR/previous" "$DEST"; then
        printf 'Previous app retained for recovery at: %q\n' "$BACKUP_DIR/previous" >&2
      fi
    fi
  fi
  [[ -z "$INSTALL_STAGE" ]] || rm -rf -- "$INSTALL_STAGE"
  [[ -z "$LINK_STAGE" ]] || rm -rf -- "$LINK_STAGE"
  if [[ -n "$BACKUP_DIR" ]]; then
    if [[ "$COMMITTED" == 1 || ! -e "$BACKUP_DIR/previous" ]]; then
      rm -rf -- "$BACKUP_DIR"
    fi
  fi
  [[ -z "$TEMP_DIR" ]] || rm -rf -- "$TEMP_DIR"
  return "$status"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM

while [[ $# -gt 0 ]]; do
  case "$1" in
    --help|-h) help; exit 0 ;;
    --replace) REPLACE=1; shift ;;
    --version|--prefix|--download-base)
      [[ $# -ge 2 && -n "$2" ]] || fail "$1 needs a value."
      case "$1" in
        --version) VERSION=$2 ;;
        --prefix) PREFIX=$2 ;;
        --download-base) DOWNLOAD_BASE=$2 ;;
      esac
      shift 2 ;;
    *) fail "Unknown option: $1. Use --help." ;;
  esac
done

[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9]+([.-][A-Za-z0-9]+)*)?$ ]] || fail 'Use a version such as 0.2.0-preview.3.'
[[ ${#VERSION} -le 80 ]] || fail 'The version is too long.'
[[ "$PREFIX" != *$'\n'* && "$PREFIX" != *$'\r'* ]] || fail 'The prefix must not contain a line break.'
case "$PREFIX" in /*) ;; *) PREFIX="$PWD/$PREFIX" ;; esac
while [[ "$DOWNLOAD_BASE" == */ ]]; do DOWNLOAD_BASE=${DOWNLOAD_BASE%/}; done
[[ "$DOWNLOAD_BASE" == https://* ]] || fail 'The download base must use HTTPS.'
[[ "$DOWNLOAD_BASE" != *[[:space:]]* && "$DOWNLOAD_BASE" != *'?'* && "$DOWNLOAD_BASE" != *'#'* && "$DOWNLOAD_BASE" != *'\'* ]] || fail 'Use an HTTPS download base without spaces, query parameters, or fragments.'
authority=${DOWNLOAD_BASE#https://}
authority=${authority%%/*}
[[ -n "$authority" && "$authority" != *@* ]] || fail 'The download base must have a host and no embedded credentials.'

OS=$(uname -s)
CPU=$(uname -m)
case "$OS/$CPU" in
  Linux/x86_64|Linux/amd64) PLATFORM=linux; ARCH=amd64 ;;
  Linux/aarch64|Linux/arm64) PLATFORM=linux; ARCH=arm64 ;;
  Linux/armv7l|Linux/armv7|Linux/armv8l) PLATFORM=linux; ARCH=armv7 ;;
  Darwin/x86_64|Darwin/amd64) PLATFORM=darwin; ARCH=amd64 ;;
  Darwin/arm64|Darwin/aarch64) PLATFORM=darwin; ARCH=arm64 ;;
  *) fail "Unsupported platform: $OS/$CPU. Use a supported download; Armv6 is not supported." ;;
esac
if [[ "$PLATFORM" == darwin ]]; then
  kernel=$(uname -r)
  kernel_major=${kernel%%.*}
  [[ "$kernel_major" =~ ^[0-9]+$ && "$kernel_major" -ge 22 ]] || fail 'This Mac build needs macOS 13 or later.'
  EXT=zip
  TARGET='Pi Nope.app/Contents/MacOS/pi-nope-server'
  command -v unzip >/dev/null 2>&1 || fail 'The unzip command is required.'
else
  EXT=tar.gz
  TARGET=pi-nope
  command -v tar >/dev/null 2>&1 || fail 'The tar command is required.'
fi
for tool in curl awk mktemp find; do
  command -v "$tool" >/dev/null 2>&1 || fail "The $tool command is required."
done
if command -v sha256sum >/dev/null 2>&1; then
  HASH_TOOL=sha256sum
elif command -v shasum >/dev/null 2>&1; then
  HASH_TOOL=shasum
else
  fail 'Install sha256sum or shasum before using this installer.'
fi

PACKAGE="pi-nope_${VERSION}_${PLATFORM}_${ARCH}"
ARCHIVE="$PACKAGE.$EXT"
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/pi-nope-install.XXXXXXXX")
download() {
  curl --fail --silent --show-error --location --globoff \
    --proto '=https' --proto-redir '=https' --tlsv1.2 \
    --connect-timeout 15 --max-time 300 --output "$2" --url "$1" || fail 'Download failed. No app files were replaced.'
}
printf 'Downloading Pi Nope %s for %s/%s...\n' "$VERSION" "$PLATFORM" "$ARCH"
download "$DOWNLOAD_BASE/$VERSION/SHA256SUMS" "$TEMP_DIR/SHA256SUMS"
download "$DOWNLOAD_BASE/$VERSION/$ARCHIVE" "$TEMP_DIR/$ARCHIVE"
expected=$(awk -v name="$ARCHIVE" '
  $2 == name || $2 == "*" name { n++; hash = $1; if (NF != 2) bad = 1 }
  END { if (n != 1 || bad) exit 1; print tolower(hash) }
' "$TEMP_DIR/SHA256SUMS") || fail 'SHA256SUMS must contain exactly one entry for this archive.'
[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || fail 'The archive checksum entry is invalid.'
if [[ "$HASH_TOOL" == sha256sum ]]; then
  actual=$(sha256sum "$TEMP_DIR/$ARCHIVE") || fail 'Could not compute the archive checksum.'
else
  actual=$(shasum -a 256 "$TEMP_DIR/$ARCHIVE") || fail 'Could not compute the archive checksum.'
fi
[[ "${actual%% *}" == "$expected" ]] || fail 'Checksum mismatch. No app files were replaced.'

# Read only known members to stdout. Archive paths and links are never extracted
# into the filesystem, so a downloaded symlink cannot redirect an output file.
if [[ "$PLATFORM" == darwin ]]; then
  unzip -Z1 "$TEMP_DIR/$ARCHIVE" > "$TEMP_DIR/members" || fail 'Could not read the ZIP archive.'
else
  tar -tzf "$TEMP_DIR/$ARCHIVE" > "$TEMP_DIR/members" || fail 'Could not read the tar archive.'
fi
while IFS= read -r member || [[ -n "$member" ]]; do
  case "$member" in "$PACKAGE"|"$PACKAGE/"*) ;; *) fail 'The archive contains an unexpected path.' ;; esac
  case "/$member/" in *'/../'*|*'/./'*) fail 'The archive contains an unsafe path.' ;; esac
  [[ "$member" != *'\'* ]] || fail 'The archive contains an unsafe path.'
done < "$TEMP_DIR/members"

PAYLOAD="$TEMP_DIR/payload"
mkdir -p "$PAYLOAD/third_party/sparkdash"
extract_member() {
  local relative=$1 destination=$2 full="$PACKAGE/$1" count
  count=$(awk -v name="$full" '$0 == name { n++ } END { print n+0 }' "$TEMP_DIR/members")
  [[ "$count" == 1 ]] || fail "Missing or repeated archive member: $relative"
  if [[ "$PLATFORM" == darwin ]]; then
    unzip -p "$TEMP_DIR/$ARCHIVE" "$full" > "$destination" || fail "Could not read $relative."
  else
    tar -xOzf "$TEMP_DIR/$ARCHIVE" -- "$full" > "$destination" || fail "Could not read $relative."
  fi
  [[ -s "$destination" ]] || fail "Archive member is empty: $relative"
  chmod 644 "$destination"
}
for relative in LICENSE THIRD_PARTY_NOTICES.md READ-ME.txt third_party/sparkdash/LICENSE-MIT third_party/sparkdash/LICENSE-APACHE-2.0 third_party/sparkdash/NOTICE; do
  extract_member "$relative" "$PAYLOAD/$relative"
done
if [[ "$PLATFORM" == darwin ]]; then
  mkdir -p "$PAYLOAD/Pi Nope.app/Contents/MacOS"
  extract_member 'Pi Nope.app/Contents/Info.plist' "$PAYLOAD/Pi Nope.app/Contents/Info.plist"
  extract_member 'Pi Nope.app/Contents/MacOS/pi-nope' "$PAYLOAD/Pi Nope.app/Contents/MacOS/pi-nope"
  extract_member "$TARGET" "$PAYLOAD/$TARGET"
  chmod 755 "$PAYLOAD/Pi Nope.app/Contents/MacOS/pi-nope"
else
  extract_member "$TARGET" "$PAYLOAD/$TARGET"
fi
chmod 755 "$PAYLOAD/$TARGET"
printf 'pi-nope-installer-v1\n' > "$PAYLOAD/.installer-owned"

mkdir -p "$PREFIX" || fail 'Cannot create the chosen prefix. Choose a folder your user can write.'
PREFIX=$(cd "$PREFIX" && pwd -P)
[[ "$PREFIX" != / ]] || fail 'Use a per-user prefix, not the filesystem root.'
[[ ! -L "$PREFIX/bin" && ! -L "$PREFIX/share" ]] || fail 'The prefix bin and share folders must not be symlinks.'
mkdir -p "$PREFIX/bin" "$PREFIX/share"
DEST="$PREFIX/share/pi-nope"
LINK="$PREFIX/bin/pi-nope"
LINK_TARGET="../share/pi-nope/$TARGET"
[[ ! -L "$DEST" ]] || fail 'The existing app folder is a symlink. Choose another prefix.'
if [[ -e "$DEST" ]]; then
  [[ -d "$DEST" && -f "$DEST/.installer-owned" && ! -L "$DEST/.installer-owned" ]] || fail 'The app folder is not managed by this installer. Choose another prefix.'
  [[ "$(cat "$DEST/.installer-owned")" == pi-nope-installer-v1 ]] || fail 'The existing app folder is not recognized.'
  [[ "$REPLACE" == 1 ]] || fail 'Pi Nope is already installed here. Quit it, then run this command again with --replace.'
  find "$DEST" -mindepth 1 -print > "$TEMP_DIR/installed-paths" || fail 'Cannot check the existing app folder.'
  while IFS= read -r path || [[ -n "$path" ]]; do
    [[ ! -L "$path" ]] || fail 'The app folder contains a link that this installer did not create.'
    case "${path#"$DEST/"}" in
      .installer-owned|LICENSE|THIRD_PARTY_NOTICES.md|READ-ME.txt|pi-nope|third_party|third_party/sparkdash|third_party/sparkdash/LICENSE-MIT|third_party/sparkdash/LICENSE-APACHE-2.0|third_party/sparkdash/NOTICE|'Pi Nope.app'|'Pi Nope.app/Contents'|'Pi Nope.app/Contents/Info.plist'|'Pi Nope.app/Contents/MacOS'|'Pi Nope.app/Contents/MacOS/pi-nope'|'Pi Nope.app/Contents/MacOS/pi-nope-server') ;;
      *) fail 'The app folder contains extra files. Move them somewhere safe or choose a new prefix; they will not be deleted.' ;;
    esac
  done < "$TEMP_DIR/installed-paths"
fi
if [[ -e "$LINK" || -L "$LINK" ]]; then
  [[ -d "$DEST" && -L "$LINK" ]] || fail 'The command path already exists and is not an installer-managed link. Choose another prefix.'
  [[ "$(readlink "$LINK")" == "$LINK_TARGET" ]] || fail 'The command path points somewhere else. Choose another prefix.'
  LINK_WAS_PRESENT=1
fi

# Stage on the destination filesystem. Keep the previous app until both moves
# succeed, so an interrupted replacement can restore it without touching config.
INSTALL_STAGE=$(mktemp -d "$PREFIX/share/.pi-nope-stage.XXXXXXXX")
cp -pR "$PAYLOAD/." "$INSTALL_STAGE/"
LINK_STAGE=$(mktemp -d "$PREFIX/bin/.pi-nope-link.XXXXXXXX")
ln -s "$LINK_TARGET" "$LINK_STAGE/pi-nope"
if [[ -e "$DEST" ]]; then
  BACKUP_DIR=$(mktemp -d "$PREFIX/share/.pi-nope-backup.XXXXXXXX")
  mv -- "$DEST" "$BACKUP_DIR/previous"
fi
SWAPPED=1
mv -- "$INSTALL_STAGE" "$DEST"
mv -f -- "$LINK_STAGE/pi-nope" "$LINK"
COMMITTED=1

printf '\nInstalled Pi Nope %s. Your settings were kept.\n' "$VERSION"
printf 'Run: %q\n' "$LINK"
printf 'Keep the terminal open while the app runs. Use Ctrl+C or Quit Pi Nope to stop it.\n'
if [[ "$PLATFORM" == darwin ]]; then
  printf 'You can also open this Mac app: %q\n' "$DEST/Pi Nope.app"
fi
printf '\nLive data needs an existing Pi-hole v6 server and its app password.\n'
printf 'Official Pi-hole setup: https://docs.pi-hole.net/main/basic-install/\n'
printf 'This installer does not install Pi-hole, change DNS, or enable autostart.\n'
printf 'This preview is unsigned and not notarized. Keep system protections enabled.\n'
