#!/bin/bash
# VoltVault installer. Compatible with macOS Bash 3.2 and Linux Bash.
set -euo pipefail
umask 077
VERSION='2.0.7'
PAYLOAD_SHA256='3c3475daa33feb6750b7bfdcc24fdccbf8c06d62a4dba695af4323ccc54b5a7e'
DOWNLOAD='https://voltvaultapp.com/downloads/voltvault-2.0.7-installer.zip'
install_dir="$HOME/voltvault"
requested_port=''
requested_bind=''
enable_laya=0
disable_laya=0
prebuilt=0
server_image=''
web_image=''
scratch=''
lock=''
fail() { printf '\nVoltVault: %s\n' "$*" >&2; exit 1; }
cleanup() {
  if [ -n "$scratch" ]; then rm -rf -- "$scratch"; fi
  if [ -n "$lock" ]; then rmdir -- "$lock" 2>/dev/null || true; fi
}
trap cleanup EXIT
trap 'exit 130' INT TERM
while [ "$#" -gt 0 ]; do
  case "$1" in
    --dir|--port|--lan)
      [ "$#" -ge 2 ] || fail "Missing value for $1"
      case "$1" in --dir) install_dir="$2";; --port) requested_port="$2";; --lan) requested_bind="$2";; esac
      shift 2;;
    --local) requested_bind=127.0.0.1; shift;;
    --laya) enable_laya=1; shift;;
    --no-laya) disable_laya=1; shift;;
    --help|-h)
      printf 'Usage: bash install.sh [--dir PATH] [--port PORT] [--lan PRIVATE_IPV4 | --local] [--laya | --no-laya]\n\nDefault: ~/voltvault, localhost:8080, without the optional local Laya worker. Reruns preserve settings and data.\n'
      exit 0;;
    *) fail "Unknown option: $1 (use --help)";;
  esac
done
[ "$enable_laya" = 0 ] || [ "$disable_laya" = 0 ] || fail 'Choose either --laya or --no-laya.'
case "$(uname -s)" in Darwin|Linux) ;; *) fail 'Use install.ps1 on Windows.';; esac
case "$(uname -m)" in arm64|aarch64|x86_64|amd64) ;; *) fail 'This installer supports ARM64 and x86-64 hosts.';; esac
if [ -n "$requested_port" ]; then
  case "$requested_port" in *[!0-9]*|'') fail 'Port must be a number from 1024 to 65535.';; esac
  [ "$requested_port" -ge 1024 ] && [ "$requested_port" -le 65535 ] || fail 'Port must be from 1024 to 65535.'
fi
if [ -n "$requested_bind" ] && [ "$requested_bind" != 127.0.0.1 ]; then
  printf '%s\n' "$requested_bind" | awk -F. 'NF!=4 {exit 1} {for(i=1;i<=4;i++) if($i !~ /^[0-9]+$/ || $i>255 || ($i ~ /^0[0-9]/)) exit 1; if(!($1==10 || ($1==172 && $2>=16 && $2<=31) || ($1==192 && $2==168))) exit 1}' || fail '--lan needs the private IPv4 address of this machine, such as 192.168.1.20.'
fi
for tool in docker curl unzip openssl awk; do
  command -v "$tool" >/dev/null 2>&1 || fail "Missing $tool. Install it first; see https://voltvaultapp.com/setup.html"
done
if command -v sha256sum >/dev/null 2>&1; then
  hash_file() { sha256sum "$1" | awk '{print $1}'; }
elif command -v shasum >/dev/null 2>&1; then
  hash_file() { shasum -a 256 "$1" | awk '{print $1}'; }
else fail 'Install sha256sum or shasum to verify the download.'; fi
docker info >/dev/null 2>&1 || fail 'Docker is not ready. Start Docker Desktop on Mac, or start Docker Engine on Linux, then rerun this command. On Linux, check that your user can access Docker.'
docker_os=$(docker info --format '{{.OSType}}')
[ "$docker_os" = linux ] || fail 'Use Linux containers in Docker.'
case "$(docker info --format '{{.Architecture}}')" in
  arm64|aarch64) docker_arch=arm64;;
  amd64|x86_64) docker_arch=amd64;;
  *) fail 'Prebuilt images support Linux ARM64 and AMD64.';;
esac
docker compose version >/dev/null 2>&1 || fail 'Docker Compose is missing. Install the current Compose plugin or Docker Desktop.'
docker compose up --help | grep -q -- '--wait-timeout' || fail 'Update Docker Compose; this installer needs --wait-timeout support.'
case "$install_dir" in /*) ;; *) install_dir="$PWD/$install_dir";; esac
[ ! -L "$install_dir" ] || fail 'Choose an installation directory that is not a symlink.'
parent=$(dirname -- "$install_dir")
[ -d "$parent" ] || fail 'The parent of --dir must already exist.'
parent=$(cd "$parent" && pwd -P)
install_dir="$parent/$(basename -- "$install_dir")"
[ "$install_dir" != / ] || fail 'Choose a dedicated installation directory.'
if ! mkdir "${install_dir}.install-lock" 2>/dev/null; then fail "Another install may be running. If it was interrupted, remove the empty ${install_dir}.install-lock directory and rerun."; fi
lock="${install_dir}.install-lock"
marker="$install_dir/.voltvault-install"
if [ -e "$install_dir" ]; then
  [ -d "$install_dir" ] && [ -f "$marker" ] || fail "Leaving existing path untouched: $install_dir. Choose a new --dir, or use the manual upgrade guide."
  [ "$(cat "$marker")" = "installer-v1:$VERSION" ] || [ -f "$install_dir/.voltvault-updater/active.json" ] || fail 'This is a different installer release. Back up first and use the upgrade guide; this installer will not replace it.'
  for file in .env compose.yaml voltvault-server/Dockerfile voltvault-web-client/Dockerfile; do
    [ -f "$install_dir/$file" ] && [ ! -L "$install_dir/$file" ] || fail "Missing or unsafe $file. Restore the installation files and original .env before continuing."
  done
  if [ -e "$install_dir/.voltvault-updater/state.json" ] || [ -e "$install_dir/.voltvault-updater/active.json" ]; then
    command -v python3 >/dev/null 2>&1 || fail 'Python 3 is required to maintain an upgraded installation.'
    python3 - "$install_dir" "$VERSION" <<'PYTHON'
import json, pathlib, sys
root=pathlib.Path(sys.argv[1]); metadata=root/'.voltvault-updater'; state_path=metadata/'state.json'; active_path=metadata/'active.json'
if metadata.is_symlink() or state_path.is_symlink() or active_path.is_symlink(): sys.exit('Unsafe updater metadata.')
if not state_path.is_file(): sys.exit('Updater metadata is missing; configuration has been retained. Restore metadata before maintenance.')
state=json.loads(state_path.read_text())
if state.get('phase') not in ('complete','aborted','rolled-back'): sys.exit('An interrupted upgrade needs recovery. Run the upgrade command with --recover first.')
if active_path.exists() and (state.get('phase')!='complete' or state.get('target')!=sys.argv[2]): sys.exit('Use the upgrade command for this release; configuration has been retained.')
PYTHON
  fi
else
  # Do not accidentally attach an existing installation's persistent volumes.
  for volume in voltvault_voltvault-data voltvault_voltvault-uploads; do
    if docker volume inspect "$volume" >/dev/null 2>&1; then fail "Existing VoltVault volume found: $volume. Use the manual upgrade guide; no data has been changed."; fi
  done
fi
project_dirs=$(docker ps -a --filter label=com.docker.compose.project=voltvault --format '{{.Label "com.docker.compose.project.working_dir"}}')
while IFS= read -r project_dir; do
  [ -z "$project_dir" ] || [ "$project_dir" = "$install_dir" ] || fail "A VoltVault Compose project already exists at $project_dir. Leaving it untouched."
done <<< "$project_dirs"
if [ ! -d "$install_dir" ]; then
  if [ "$enable_laya" = 0 ] && [ "$disable_laya" = 0 ] && [ -t 0 ]; then
    printf 'Install the optional local Laya worker for project suggestions? [y/N]: '
    IFS= read -r laya_choice
    case "$laya_choice" in y|Y|yes|YES) enable_laya=1;; ''|n|N|no|NO) ;; *) fail 'Answer y or n for the optional Laya worker.';; esac
  fi
  printf '\n[1/3] Downloading and verifying VoltVault %s…\n' "$VERSION"
  scratch=$(mktemp -d "$parent/.voltvault-install.XXXXXX")
  curl --fail --show-error --location --proto '=https' --tlsv1.2 --connect-timeout 20 --max-time 300 "$DOWNLOAD" -o "$scratch/release.zip"
  [ "$(hash_file "$scratch/release.zip")" = "$PAYLOAD_SHA256" ] || fail 'Download verification failed. Nothing has been installed; please retry.'
  unzip -q "$scratch/release.zip" -d "$scratch/release"
  staged="$scratch/release/voltvault"
  [ -f "$staged/compose.yaml" ] && [ -f "$staged/voltvault-server/Dockerfile" ] && [ -f "$staged/voltvault-web-client/Dockerfile" ] || fail 'The release is incomplete.'
  if [ -f "$staged/images.tsv" ]; then
    prebuilt=1
    for service in voltvault-server voltvault-web-client; do
      row=$(awk -F '\t' -v arch="$docker_arch" -v service="$service" '$1==arch && $2==service {print; count++} END {if(count!=1) exit 1}' "$staged/images.tsv") || fail "Missing prebuilt $service image for linux/$docker_arch."
      IFS=$'\t' read -r image_arch image_service image_file image_hash image_tag image_id <<< "$row"
      image_id=${image_id%$'\r'}
      case "$image_file" in *[!A-Za-z0-9._-]*|'') fail 'Unsafe image archive name.';; esac
      case "$image_hash" in *[!0-9a-f]*|'') fail 'Invalid image checksum.';; esac
      case "$image_id" in sha256:*) ;; *) fail 'Invalid image ID.';; esac
      [ "$image_arch" = "$docker_arch" ] && [ "$image_service" = "$service" ] && [ "$image_tag" = "$service:$VERSION-$docker_arch" ] || fail 'Invalid image manifest.'
      loaded_id=$(docker image inspect --format '{{.Id}}' "$image_tag" 2>/dev/null || true)
      if [ "$loaded_id" != "$image_id" ]; then
        printf 'Downloading verified %s image for linux/%s…\n' "$service" "$docker_arch"
        curl --fail --show-error --location --proto '=https' --tlsv1.2 --connect-timeout 20 --max-time 900 "https://voltvaultapp.com/downloads/$image_file" -o "$scratch/$image_file"
        [ "$(hash_file "$scratch/$image_file")" = "$image_hash" ] || fail 'Image checksum mismatch. Existing installation was not changed.'
        docker load --input "$scratch/$image_file" || fail 'Could not load the verified Docker image.'
      fi
      loaded_id=$(docker image inspect --format '{{.Id}}' "$image_tag" 2>/dev/null || true)
      loaded_arch=$(docker image inspect --format '{{.Architecture}}' "$image_tag" 2>/dev/null || true)
      [ "$loaded_id" = "$image_id" ] && [ "$loaded_arch" = "$docker_arch" ] || fail 'Loaded image identity or architecture differs.'
      if [ "$service" = voltvault-server ]; then server_image="$image_tag"; else web_image="$image_tag"; fi
    done
  fi
  if [ "$enable_laya" = 1 ]; then
    [ -f "$staged/voltvault-server/Dockerfile.laya" ] && grep -q '^  laya:' "$staged/compose.yaml" || fail 'This release does not include the optional Laya worker. Nothing has been installed.'
  fi
  new_session_secret=$(openssl rand -hex 32)
  new_credential_key=$(openssl rand -hex 32)
  if [ -z "${TINYFISH_API_KEY:-}" ] && [ -t 0 ]; then
    printf 'TinyFish API key for optional supplier previews (press Enter to skip; https://agent.tinyfish.ai/api-keys): '
    IFS= read -r -s TINYFISH_API_KEY
    printf '\n'
  fi
  TINYFISH_API_KEY=${TINYFISH_API_KEY:-}
  case "$TINYFISH_API_KEY" in *[!A-Za-z0-9._-]*) fail 'TINYFISH_API_KEY must contain no spaces or shell special characters.';; esac
  [ -n "$TINYFISH_API_KEY" ] || printf 'Supplier previews are disabled. Add TINYFISH_API_KEY to the private .env later to enable them.\n'
  printf 'SESSION_SECRET=%s\nDEVICE_CREDENTIAL_KEY=%s\nAPP_BIND_ADDRESS=%s\nAPP_PORT=%s\nLAYA_MATCH_ENABLED=%s\nLAYA_MATCH_URL=http://laya:8000\nTINYFISH_API_KEY=%s\n' "$new_session_secret" "$new_credential_key" "${requested_bind:-127.0.0.1}" "${requested_port:-8080}" "$([ "$enable_laya" = 1 ] && printf true || printf false)" "$TINYFISH_API_KEY" > "$staged/.env"
  if [ "$prebuilt" = 1 ]; then
    printf 'VOLTVAULT_SERVER_IMAGE=%s\nVOLTVAULT_WEB_IMAGE=%s\n' "$server_image" "$web_image" >> "$staged/.env"
  fi
  unset new_session_secret new_credential_key
  printf 'installer-v1:%s\n' "$VERSION" > "$staged/.voltvault-install"
  chmod 700 "$staged"
  chmod 600 "$staged/.env"
  mv "$staged" "$install_dir"
else
  printf '\n[1/3] Reusing your installation, secrets, and persistent data.\n'
  if [ -f "$install_dir/images.tsv" ] || [ -f "$install_dir/.voltvault-updater/active.json" ]; then prebuilt=1; fi
  if [ "$enable_laya" = 1 ]; then
    if [ -f "$install_dir/.voltvault-updater/active.json" ]; then
      python3 - "$install_dir/.voltvault-updater/active.json" <<'PYTHON'
import json,sys
service=json.load(open(sys.argv[1]))['services'].get('laya',{})
if service.get('profiles') != ['laya'] or not service.get('build'):
    sys.exit('This release does not include the optional Laya worker. Upgrade first.')
PYTHON
    else
      [ -f "$install_dir/voltvault-server/Dockerfile.laya" ] && grep -q '^  laya:' "$install_dir/compose.yaml" || fail 'This release does not include the optional Laya worker. Upgrade first.'
    fi
  fi
  if [ -n "$requested_port$requested_bind" ]; then
    awk -v port="$requested_port" -v bind="$requested_bind" 'BEGIN{FS=OFS="="} $1=="APP_PORT" && port!="" {$2=port} $1=="APP_BIND_ADDRESS" && bind!="" {$2=bind} {print}' "$install_dir/.env" > "$install_dir/.env.new"
    mv "$install_dir/.env.new" "$install_dir/.env"
  fi
fi
read_setting() { awk -F= -v key="$1" '$1==key {print $2}' "$install_dir/.env"; }
if [ "$enable_laya" = 1 ]; then
  if grep -q '^LAYA_MATCH_ENABLED=' "$install_dir/.env"; then
    awk 'BEGIN{FS=OFS="="} $1=="LAYA_MATCH_ENABLED" {$2="true"} {print}' "$install_dir/.env" > "$install_dir/.env.new"
    mv "$install_dir/.env.new" "$install_dir/.env"
  else
    printf 'LAYA_MATCH_ENABLED=true\nLAYA_MATCH_URL=http://laya:8000\n' >> "$install_dir/.env"
  fi
fi
if [ "$disable_laya" = 1 ] && [ "$(read_setting LAYA_MATCH_ENABLED)" = true ]; then
  awk 'BEGIN{FS=OFS="="} $1=="LAYA_MATCH_ENABLED" {$2="false"} {print}' "$install_dir/.env" > "$install_dir/.env.new"
  mv "$install_dir/.env.new" "$install_dir/.env"
fi
match_enabled=$(read_setting LAYA_MATCH_ENABLED)
[ -z "$match_enabled" ] || [ "$match_enabled" = false ] || [ "$match_enabled" = true ] || fail 'LAYA_MATCH_ENABLED must be true or false in .env.'
if [ "$match_enabled" = true ]; then
  [ "$(read_setting LAYA_MATCH_URL)" = http://laya:8000 ] || fail 'The official Laya worker URL must be http://laya:8000.'
fi
for key in SESSION_SECRET DEVICE_CREDENTIAL_KEY; do
  secret=$(read_setting "$key")
  [ "${#secret}" -eq 64 ] || fail "Invalid $key in .env. Restore the original configuration; secrets will not be regenerated."
  case "$secret" in *[!0-9a-fA-F]*) fail "Invalid $key in .env.";; esac
done
tinyfish_key=$(read_setting TINYFISH_API_KEY)
case "$tinyfish_key" in *[!A-Za-z0-9._-]*) fail 'TINYFISH_API_KEY in .env contains unsupported characters.';; esac
unset tinyfish_key
unset secret
bind=$(read_setting APP_BIND_ADDRESS)
port=$(read_setting APP_PORT)
[ -n "$bind" ] && [ -n "$port" ] || fail 'APP_BIND_ADDRESS or APP_PORT is missing from .env.'
case "$port" in *[!0-9]*|'') fail 'Invalid APP_PORT in .env.';; esac
[ "$port" -ge 1024 ] && [ "$port" -le 65535 ] || fail 'Invalid APP_PORT in .env.'
if [ "$bind" != 127.0.0.1 ]; then
  printf '%s\n' "$bind" | awk -F. 'NF!=4 {exit 1} {for(i=1;i<=4;i++) if($i !~ /^[0-9]+$/ || $i>255 || ($i ~ /^0[0-9]/)) exit 1; if(!($1==10 || ($1==172 && $2>=16 && $2<=31) || ($1==192 && $2==168))) exit 1}' || fail 'Invalid APP_BIND_ADDRESS in .env.'
fi
cd "$install_dir"
# A caller's environment must never override saved secrets or network settings.
unset SESSION_SECRET DEVICE_CREDENTIAL_KEY APP_BIND_ADDRESS APP_PORT LAYA_MATCH_ENABLED LAYA_MATCH_URL TINYFISH_API_KEY VOLTVAULT_SERVER_IMAGE VOLTVAULT_WEB_IMAGE COMPOSE_FILE COMPOSE_PROFILES COMPOSE_ENV_FILES
compose_file="$install_dir/compose.yaml"
if [ -f "$install_dir/.voltvault-updater/active.json" ]; then
  command -v python3 >/dev/null 2>&1 || fail 'Python 3 is required to maintain an upgraded installation.'
  python3 - "$install_dir" "$VERSION" <<'PYTHON'
import json, os, pathlib, sys
root=pathlib.Path(sys.argv[1]); state_path=root/'.voltvault-updater/state.json'; active_path=root/'.voltvault-updater/active.json'
if state_path.is_symlink() or active_path.is_symlink(): sys.exit('Unsafe updater metadata.')
state=json.loads(state_path.read_text())
if state.get('phase')!='complete' or state.get('target')!=sys.argv[2]: sys.exit('Run the upgrade command first; this installation needs another release or recovery.')
active=json.loads(active_path.read_text())
settings=dict(line.split('=',1) for line in (root/'.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
port=active['services']['voltvault-web-client']['ports'][0]
port['host_ip']=settings['APP_BIND_ADDRESS']; port['published']=settings['APP_PORT']
active['services']['voltvault-server'].setdefault('environment', {})['LAYA_MATCH_ENABLED']=settings.get('LAYA_MATCH_ENABLED', 'false')
active['services']['voltvault-server']['environment']['LAYA_MATCH_URL']=settings.get('LAYA_MATCH_URL', 'http://laya:8000')
active['services']['voltvault-server']['environment']['TINYFISH_API_KEY']=settings.get('TINYFISH_API_KEY', '')
temp=active_path.with_suffix('.new'); temp.write_text(json.dumps(active,indent=2)); temp.chmod(0o600); os.replace(temp,active_path)
PYTHON
  compose_file="$install_dir/.voltvault-updater/active.json"
fi
compose() {
  if [ "$match_enabled" = true ]; then
    docker compose --project-directory "$install_dir" --env-file "$install_dir/.env" -p voltvault -f "$compose_file" --profile laya "$@"
  else
    docker compose --project-directory "$install_dir" --env-file "$install_dir/.env" -p voltvault -f "$compose_file" "$@"
  fi
}
if [ "$disable_laya" = 1 ] && docker compose --project-directory "$install_dir" --env-file "$install_dir/.env" -p voltvault -f "$compose_file" --profile laya config --services | grep -qx laya; then
  docker compose --project-directory "$install_dir" --env-file "$install_dir/.env" -p voltvault -f "$compose_file" --profile laya stop laya || fail 'Could not stop the optional Laya worker. Check Docker and rerun.'
fi
compose config --quiet
if [ "$prebuilt" = 1 ]; then
  printf '\n[2/3] Starting verified prebuilt VoltVault images.\n'
  if [ "$match_enabled" = true ]; then compose build laya || fail 'Could not build the optional Laya worker.'; fi
  start_flags=(--no-build)
else
  printf '\n[2/3] Building and starting VoltVault. The first build can take several minutes.\n'
  start_flags=(--build)
fi
if ! compose up -d "${start_flags[@]}" --wait --wait-timeout 300; then
  printf '\nStartup failed. Configuration and data have been retained.\n' >&2
  compose logs --tail=60 >&2 || true
  fail 'Check the Docker output above, then rerun the installer. For a busy port, add --port 8081.'
fi
printf '\n[3/3] Checking the browser connection…\n'
url="http://$bind:$port"
curl --fail --silent --show-error --noproxy '*' --max-time 15 "$url/api/ready" >/dev/null || fail "Containers started, but $url is not reachable. Check the bind address, Docker port mapping, and firewall. Your data has been retained."
printf '\nVoltVault is ready. Open %s\nCreate your administrator account in the browser.\n\nInstalled in: %s\n' "$url" "$install_dir"
printf 'To view logs: cd %q && docker compose -p voltvault logs --tail=100\n' "$install_dir"
printf 'Keep .env safe: it contains the keys needed for saved printer credentials.\n'
if [ "$bind" != 127.0.0.1 ]; then printf 'LAN access is enabled. Create the administrator immediately; do not port-forward this address.\n'; fi
