#!/usr/bin/env bash
# donefast-sync: install and run the DoneFast sync server with Docker.
#
#   donefast-sync setup       Configure it (generates the admin password) and start it
#   donefast-sync start       Start it in the background; it comes back after a reboot
#   donefast-sync run         Run it in the foreground until Ctrl+C (on demand)
#   donefast-sync stop        Stop it; it stays stopped across reboots
#   donefast-sync status      Show whether it is running and well
#   donefast-sync logs        Follow the server's log
#   donefast-sync backup      Back up the database (briefly stops the server)
#   donefast-sync restore F   Put a backup back
#   donefast-sync upgrade     Back up, upgrade, check, and roll back if it fails
#   donefast-sync password    Show the admin password (--reset makes a new one)
#   donefast-sync uninstall   Remove it; the data is kept unless you add --purge
#
# Requirements: Docker with the Compose plugin. Works with the bash that ships
# with macOS (3.2).

set -euo pipefail

DEFAULT_VERSION=1.0.2
IMAGE_REPO=ghcr.io/asprotunity/donefast-sync
PROJECT=donefast-sync
HOME_DIR="${DONEFAST_SYNC_HOME:-$HOME/.donefast-sync}"
ENV_FILE="$HOME_DIR/.env"
COMPOSE_FILE="$HOME_DIR/docker-compose.yml"
BACKUP_DIR="$HOME_DIR/backups"
KEEP_BACKUPS=7

# ── Helpers ──────────────────────────────────────────────────────────────────

red()   { printf '\033[31m%s\033[0m\n' "$*" >&2; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
bold()  { printf '\033[1m%s\033[0m\n' "$*"; }
die()   { red "Error: $*"; exit 1; }

check_docker() {
  command -v docker >/dev/null 2>&1 \
    || die "Docker is not installed. Install it from https://docs.docker.com/get-docker/"
  docker compose version >/dev/null 2>&1 \
    || die "The Docker Compose plugin is missing. Docker Desktop includes it."
  docker info >/dev/null 2>&1 \
    || die "Docker is installed but not running. Start Docker and try again."
}

is_set_up() { [[ -f "$ENV_FILE" && -f "$COMPOSE_FILE" ]]; }

need_set_up() {
  is_set_up || die "not set up yet. Run: donefast-sync setup"
}

dc() {
  docker compose --project-directory "$HOME_DIR" -p "$PROJECT" -f "$COMPOSE_FILE" "$@"
}

# env_get KEY - the value of KEY in the env file (empty if absent).
env_get() {
  { grep -E "^$1=" "$ENV_FILE" 2>/dev/null || true; } | head -n 1 | cut -d= -f2-
}

# env_set KEY VALUE - rewrites the env file, keeping it private.
env_set() {
  local tmp
  tmp="$(umask 077; mktemp "$HOME_DIR/.env.XXXXXX")"
  { grep -v -E "^$1=" "$ENV_FILE" 2>/dev/null || true; } > "$tmp"
  printf '%s=%s\n' "$1" "$2" >> "$tmp"
  chmod 600 "$tmp"
  mv "$tmp" "$ENV_FILE"
}

generate_password() {
  # 24 letters and digits: easy to copy, no characters a shell or compose file
  # would treat specially. A fixed amount of random bytes, because `tr` reading
  # /dev/urandom itself never ends if whatever started this ignores SIGPIPE.
  head -c 1024 /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 24 || true
}

lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }

confirm() {
  local answer
  read -r -p "$1 [y/N]: " answer
  [[ "$(lower "$answer")" == "y" ]]
}

is_running() {
  [[ -n "$(dc ps -q --status running sync 2>/dev/null || true)" ]]
}

# wait_healthy [seconds] - true once the server answers its health check.
wait_healthy() {
  local limit="${1:-60}" i=0 id state
  while [[ $i -lt $limit ]]; do
    id="$(dc ps -q sync 2>/dev/null || true)"
    if [[ -n "$id" ]]; then
      state="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$id" 2>/dev/null || true)"
      if [[ "$state" == "healthy" || "$state" == "running" ]]; then return 0; fi
    fi
    sleep 2
    i=$((i + 2))
  done
  return 1
}

server_url() {
  local access domain port
  access="$(env_get SYNC_ACCESS)"
  domain="$(env_get SYNC_DOMAIN)"
  port="$(env_get SYNC_PORT)"
  case "$access" in
    https) echo "https://$domain" ;;
    local) echo "http://localhost:$port" ;;
    *)
      local host
      host="$(hostname 2>/dev/null || echo this-machine)"
      echo "http://$host:$port"
      ;;
  esac
}

# ── setup ────────────────────────────────────────────────────────────────────

write_config() {
  local access="$1" port="$2" data="$3" domain="$4" bind="$5"

  {
    echo "services:"
    echo "  sync:"
    echo "    image: \${SYNC_IMAGE}"
    if [[ "$access" == "https" ]]; then
      echo "    expose:"
      echo "      - \"8080\""
    else
      echo "    ports:"
      echo "      - \"\${SYNC_BIND}:\${SYNC_PORT}:8080\""
    fi
    echo "    volumes:"
    echo "      - \${SYNC_DATA}:/data"
    echo "    environment:"
    echo "      SYNC_DB_PATH: /data/sync.sqlite"
    echo "      SYNC_PORT: 8080"
    echo "      SYNC_ADMIN_PASSWORD: \${SYNC_ADMIN_PASSWORD:?run donefast-sync setup}"
    if [[ "$access" == "https" ]]; then
      echo "      # Only Caddy can reach the server: believe the client address it reports."
      echo "      SYNC_TRUSTED_PROXIES: \"10.0.0.0/8,172.16.0.0/12,192.168.0.0/16\""
    fi
    echo "    restart: \${SYNC_RESTART:-unless-stopped}"
    if [[ "$access" == "https" ]]; then
      echo ""
      echo "  caddy:"
      echo "    build:"
      echo "      context: ."
      echo "      dockerfile: Dockerfile.caddy"
      echo "    ports:"
      echo "      - \"80:80\""
      echo "      - \"443:443\""
      echo "      - \"443:443/udp\""
      echo "    volumes:"
      echo "      - ./Caddyfile:/etc/caddy/Caddyfile:ro"
      echo "      - caddy-data:/data"
      echo "      - caddy-config:/config"
      echo "    restart: \${SYNC_RESTART:-unless-stopped}"
    fi
    if [[ "$data" != /* || "$access" == "https" ]]; then
      echo ""
      echo "volumes:"
      if [[ "$data" != /* ]]; then
        echo "  $data:"
        echo "    name: $data"
      fi
      if [[ "$access" == "https" ]]; then
        echo "  caddy-data:"
        echo "  caddy-config:"
      fi
    fi
  } > "$COMPOSE_FILE"

  if [[ "$access" == "https" ]]; then
    cat > "$HOME_DIR/Caddyfile" <<EOF
{
    order rate_limit before reverse_proxy
}

$domain {
    rate_limit {
        zone per_ip {
            key {remote_host}
            events 120
            window 1m
        }
    }
    reverse_proxy sync:8080
}
EOF
    cat > "$HOME_DIR/Dockerfile.caddy" <<'EOF'
FROM caddy:builder AS build
RUN xcaddy build --with github.com/mholt/caddy-ratelimit

FROM caddy:latest
COPY --from=build /usr/bin/caddy /usr/bin/caddy
EOF
  fi
}

legacy_install_found() {
  [[ -f /etc/systemd/system/donefast-sync.service ]] \
    || [[ -f "$HOME/Library/LaunchAgents/com.asprotunity.donefast-sync.plist" ]]
}

cmd_setup() {
  local access=lan port=8080 data="donefast-sync-data" domain="" version=""
  local force=false start=true
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --access)   access="${2:-}"; shift 2 ;;
      --port)     port="${2:-}"; shift 2 ;;
      --domain)   domain="${2:-}"; shift 2 ;;
      --data-dir) data="${2:-}"; shift 2 ;;
      --version)  version="${2:-}"; shift 2 ;;
      --force)    force=true; shift ;;
      --no-start) start=false; shift ;;
      *) die "setup: unknown option $1 (see: donefast-sync help)" ;;
    esac
  done

  case "$access" in
    lan|local) ;;
    https) [[ -n "$domain" ]] || die "--access https needs --domain, for example --domain sync.example.com" ;;
    *) die "--access must be lan, local or https" ;;
  esac
  [[ "$port" =~ ^[0-9]+$ && "$port" -ge 1 && "$port" -le 65535 ]] || die "--port must be a number from 1 to 65535"
  if [[ "$data" == /* ]]; then
    mkdir -p "$data"
    data="$(cd "$data" && pwd)"
  elif [[ ! "$data" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then
    die "--data-dir must be an absolute path"
  fi

  check_docker

  if is_set_up && ! $force; then
    green "Already set up in $HOME_DIR (nothing was changed)."
    echo "Start it with: donefast-sync start   (or --force to set it up again, keeping the password)"
    return 0
  fi
  if legacy_install_found && ! $force; then
    red "An older install of the sync server runs as a system service on this machine."
    red "Remove it first with the old uninstall.sh (keep its data), then run setup with"
    red "--data-dir <its data directory> to carry on with the same database. Or add --force."
    exit 1
  fi

  mkdir -p "$HOME_DIR"
  chmod 700 "$HOME_DIR"

  local password bind=0.0.0.0
  password="$(env_get SYNC_ADMIN_PASSWORD)"
  [[ -n "$password" ]] || password="$(generate_password)"
  [[ "$access" == "local" ]] && bind=127.0.0.1

  # Setting up again keeps the version running: going back to the one this command
  # came with could leave an older server on data a newer one has changed.
  local image
  if [[ -n "$version" ]]; then
    image="$IMAGE_REPO:$version"
  else
    image="$(env_get SYNC_IMAGE)"
    [[ -n "$image" ]] || image="$IMAGE_REPO:$DEFAULT_VERSION"
  fi

  umask 077
  : > "$ENV_FILE"
  chmod 600 "$ENV_FILE"
  env_set SYNC_IMAGE "$image"
  env_set SYNC_ADMIN_PASSWORD "$password"
  env_set SYNC_ACCESS "$access"
  env_set SYNC_BIND "$bind"
  env_set SYNC_PORT "$port"
  env_set SYNC_DATA "$data"
  env_set SYNC_DOMAIN "$domain"
  umask 022
  write_config "$access" "$port" "$data" "$domain" "$bind"

  green "✓ Configuration written to $HOME_DIR"
  if $start; then
    cmd_start
  fi

  echo ""
  bold "Admin page: $(server_url)/admin"
  echo "Password:   $password   (show it again with: donefast-sync password)"
  if [[ "$access" == "lan" ]]; then
    echo ""
    echo "Reachable from other devices on your network, over plain HTTP: anyone on that"
    echo "network can read what travels. Use it on a network you trust or over Tailscale,"
    echo "or run: donefast-sync setup --force --access https --domain <your domain>"
  fi
}

# ── start / run / stop / status / logs ───────────────────────────────────────

cmd_start() {
  need_set_up
  check_docker
  echo "Starting the sync server..."
  dc up -d --build
  if wait_healthy 60; then
    green "✓ Running at $(server_url)"
    if [[ "$(uname -s)" == "Darwin" ]]; then
      echo "To have it start again after a restart, turn on \"Start Docker Desktop when you sign in\"."
    fi
  else
    red "The server did not become healthy within a minute. Its log:"
    dc logs --tail 30 sync >&2 || true
    exit 1
  fi
}

cmd_run() {
  need_set_up
  check_docker
  bold "Running at $(server_url)/admin - press Ctrl+C to stop."
  # Not restarted by Docker: this run is on demand.
  SYNC_RESTART=no dc up --build
}

cmd_stop() {
  need_set_up
  check_docker
  dc stop >/dev/null 2>&1
  green "✓ Stopped. It stays stopped until you run: donefast-sync start"
}

cmd_status() {
  need_set_up
  check_docker
  if is_running && wait_healthy 10; then
    green "✓ Running and healthy at $(server_url)"
  elif is_running; then
    red "Running but not answering its health check. See: donefast-sync logs"
  else
    echo "Not running. Start it with: donefast-sync start"
  fi
  echo "Image: $(env_get SYNC_IMAGE)"
  echo "Data:  $(env_get SYNC_DATA)"
  local count
  count="$(ls "$BACKUP_DIR"/*.tgz 2>/dev/null | wc -l | tr -d ' ')"
  echo "Backups: $count in $BACKUP_DIR"
}

cmd_logs() {
  need_set_up
  check_docker
  dc logs -f --tail 100
}

# ── backup / restore ─────────────────────────────────────────────────────────

# In a throwaway container from the server's own image (it has tar and sh), so
# that nothing needs installing and file ownership inside the volume is no
# problem. The database is in WAL mode: copying it while the server runs can give
# an inconsistent copy, so the server is stopped for the moment it takes.
cmd_backup() {
  need_set_up
  check_docker
  mkdir -p "$BACKUP_DIR"
  chmod 700 "$BACKUP_DIR"
  local name="sync-$(date +%Y%m%d-%H%M%S).tgz" was_running=false
  if is_running; then was_running=true; dc stop sync >/dev/null 2>&1; fi

  local ok=true
  docker run --rm --entrypoint sh \
    -v "$(env_get SYNC_DATA):/data:ro" -v "$BACKUP_DIR:/backup" \
    "$(env_get SYNC_IMAGE)" \
    -c "tar czf /backup/$name -C /data . && chown $(id -u):$(id -g) /backup/$name && chmod 600 /backup/$name" \
    || ok=false

  if $was_running; then dc start sync >/dev/null 2>&1; fi
  $ok || { rm -f "$BACKUP_DIR/$name"; die "the backup failed; the server was left as it was"; }

  # Keep the newest few.
  ls -1t "$BACKUP_DIR"/sync-*.tgz 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1)) | while read -r old; do rm -f "$old"; done
  green "✓ Backup saved: $BACKUP_DIR/$name"
  LAST_BACKUP="$BACKUP_DIR/$name"
}

cmd_restore() {
  need_set_up
  check_docker
  local file="${1:-}" yes=false
  [[ "${2:-}" == "--yes" || "$file" == "--yes" ]] && yes=true
  [[ "$file" == "--yes" ]] && file="${2:-}"
  [[ -n "$file" && -f "$file" ]] || die "usage: donefast-sync restore <backup file> [--yes]  (backups are in $BACKUP_DIR)"
  file="$(cd "$(dirname "$file")" && pwd)/$(basename "$file")"
  $yes || confirm "This replaces the server's current data with $file. Continue?" || { echo "Nothing was changed."; return 0; }

  cmd_backup   # a safety copy of what is there now
  local was_running=false
  if is_running; then was_running=true; dc stop sync >/dev/null 2>&1; fi
  docker run --rm --entrypoint sh \
    -v "$(env_get SYNC_DATA):/data" -v "$file:/restore.tgz:ro" \
    "$(env_get SYNC_IMAGE)" \
    -c 'find /data -mindepth 1 -delete && tar xzf /restore.tgz -C /data' \
    || die "the restore failed; the safety copy is $LAST_BACKUP"
  if $was_running; then cmd_start; fi
  green "✓ Restored from $file"
}

# ── upgrade ──────────────────────────────────────────────────────────────────

cmd_upgrade() {
  need_set_up
  check_docker
  local version="" previous_image newest
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --version) version="${2:-}"; shift 2 ;;
      *) die "upgrade: unknown option $1" ;;
    esac
  done
  previous_image="$(env_get SYNC_IMAGE)"
  [[ -n "$version" ]] && newest="$IMAGE_REPO:$version" || newest="$previous_image"

  # Fetch first: a mistyped version should fail before anything is stopped.
  echo "Pulling $newest ..."
  docker pull "$newest" >/dev/null 2>&1 || docker image inspect "$newest" >/dev/null 2>&1 \
    || die "could not pull $newest; nothing was changed"

  # Some upgrades change the database in ways an older server cannot read, so
  # nothing is touched unless there is a backup to go back to.
  cmd_backup
  local backup="$LAST_BACKUP"

  # Keep what is installed now, so a failed upgrade can go back to it.
  docker tag "$previous_image" "$PROJECT-previous" 2>/dev/null || true

  env_set SYNC_IMAGE "$newest"
  dc up -d --build --force-recreate >/dev/null 2>&1

  if wait_healthy 90; then
    green "✓ Upgraded and healthy at $(server_url)"
    return 0
  fi

  red "The upgraded server did not become healthy. Going back to the previous version."
  dc logs --tail 20 sync >&2 || true
  dc stop >/dev/null 2>&1 || true
  docker tag "$PROJECT-previous" "$previous_image"
  env_set SYNC_IMAGE "$previous_image"
  docker run --rm --entrypoint sh \
    -v "$(env_get SYNC_DATA):/data" -v "$backup:/restore.tgz:ro" \
    "$previous_image" \
    -c 'find /data -mindepth 1 -delete && tar xzf /restore.tgz -C /data'
  dc up -d >/dev/null 2>&1
  if wait_healthy 60; then
    red "Rolled back: the previous version runs again, with the data as it was before the upgrade."
  else
    red "The previous version did not start either. Your data is safe in $backup."
  fi
  exit 1
}

# ── password / uninstall / help ──────────────────────────────────────────────

cmd_password() {
  need_set_up
  if [[ "${1:-}" == "--reset" ]]; then
    env_set SYNC_ADMIN_PASSWORD "$(generate_password)"
    if is_running; then dc up -d >/dev/null 2>&1; wait_healthy 60 || die "the server did not restart"; fi
    green "✓ New admin password (paired devices are not affected):"
  fi
  env_get SYNC_ADMIN_PASSWORD
}

cmd_uninstall() {
  local purge=false yes=false
  for arg in "$@"; do
    case "$arg" in
      --purge) purge=true ;;
      --yes) yes=true ;;
      *) die "uninstall: unknown option $arg" ;;
    esac
  done
  is_set_up || { echo "Nothing to uninstall in $HOME_DIR."; return 0; }
  check_docker
  if $purge; then
    $yes || confirm "This DELETES the database, every backup and the configuration. Continue?" || { echo "Nothing was changed."; return 0; }
  fi
  local data
  data="$(env_get SYNC_DATA)"
  dc down >/dev/null 2>&1 || true
  if $purge; then
    [[ "$data" != /* ]] && docker volume rm "$data" >/dev/null 2>&1 || true
    [[ "$data" == /* ]] && echo "Your data directory $data was left in place; delete it yourself if you want it gone."
    rm -rf "$HOME_DIR"
    green "✓ Removed, data included."
  else
    green "✓ Stopped and removed the containers. The data (volume or $data) and"
    echo "  $HOME_DIR are kept: run setup again to carry on, or uninstall --purge to delete them."
  fi
}

cmd_help() {
  sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'
  cat <<'EOF'

setup options:
  --access lan|local|https   who can reach it (default lan: devices on your network;
                             local: this machine only; https: the internet, via Caddy)
  --domain sync.example.com  required for --access https
  --port 8080                port for lan/local
  --data-dir /path           keep the database in this folder (default: a Docker volume)
  --version 1.2.0            image version (default: the one already set up, or
                             the one this command came with)
  --no-start                 write the configuration only
EOF
}

main() {
  local command="${1:-help}"
  [[ $# -gt 0 ]] && shift
  case "$command" in
    setup)     cmd_setup "$@" ;;
    start)     cmd_start "$@" ;;
    run)       cmd_run "$@" ;;
    stop)      cmd_stop "$@" ;;
    restart)   cmd_stop && cmd_start ;;
    status)    cmd_status "$@" ;;
    logs)      cmd_logs "$@" ;;
    backup)    cmd_backup "$@" ;;
    restore)   cmd_restore "$@" ;;
    upgrade)   cmd_upgrade "$@" ;;
    password)  cmd_password "$@" ;;
    uninstall) cmd_uninstall "$@" ;;
    help|-h|--help) cmd_help ;;
    *) red "Unknown command: $command"; cmd_help; exit 1 ;;
  esac
}

main "$@"
