#!/usr/bin/env bash
# Sentinel V2 prebuilt-image installer.
# For unattended/piped use, set SENTINEL_PUBLIC_HOST and SENTINEL_IMAGE_TAR.
set -Eeuo pipefail

DEFAULT_IMAGE="sentinel-v2:latest"
DEFAULT_PORT="4317"
GET_DOCKER_URL="https://get.docker.com"
DOCKER_DESKTOP_URL="https://docs.docker.com/desktop/"

SENTINEL_IMAGE="${SENTINEL_IMAGE:-$DEFAULT_IMAGE}"
SENTINEL_IMAGE_TAR="${SENTINEL_IMAGE_TAR:-}"
SENTINEL_IMAGE_SHA256="${SENTINEL_IMAGE_SHA256:-}"
SENTINEL_ALLOW_UNVERIFIED_IMAGE="${SENTINEL_ALLOW_UNVERIFIED_IMAGE:-0}"
SENTINEL_HOME="${SENTINEL_HOME:-${HOME:?HOME is required}/sentinel-v2}"
SENTINEL_PUBLIC_HOST="${SENTINEL_PUBLIC_HOST:-}"
SENTINEL_PORT="${SENTINEL_PORT:-$DEFAULT_PORT}"
SENTINEL_OCI_DIR="${SENTINEL_OCI_DIR:-}"
SENTINEL_INSTALL_DOCKER="${SENTINEL_INSTALL_DOCKER:-0}"
SENTINEL_FORCE="${SENTINEL_FORCE:-0}"
SENTINEL_HEALTH_TIMEOUT="${SENTINEL_HEALTH_TIMEOUT:-120}"

TEMP_FILES=()
IMAGE_TAR_PATH=""

cleanup() {
  local file
  for file in "${TEMP_FILES[@]:-}"; do
    if [[ -n "$file" ]]; then
      rm -f -- "$file"
    fi
  done
  return 0
}
trap cleanup EXIT INT TERM

log() { printf '%s\n' "$*"; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
is_tty() { [[ -t 0 && -t 1 ]]; }

usage() {
  cat <<'EOF'
Install a prebuilt Sentinel V2 image and start it with Docker Compose.

Usage:
  bash scripts/install.sh [options]
  curl -fsSL <url>/install.sh | SENTINEL_PUBLIC_HOST=host \
    SENTINEL_IMAGE_TAR=<url>/sentinel.tar SENTINEL_IMAGE_SHA256=<sha256> bash

Options:
  --public-host HOST       Exact hostname/IP used in the browser URL
  --image-tar PATH|URL     docker save tar to load
  --image-sha256 HASH      Expected SHA-256 (required for remote tar URLs)
  --image NAME             Image name (default sentinel-v2:latest)
  --home DIR               Install directory (default ~/sentinel-v2)
  --oci-dir DIR            Host .oci directory to mount read-only
  --port PORT              Published and container port (default 4317)
  --force                  Replace generated files instead of merging .env
  -h, --help               Show help

Set SENTINEL_INSTALL_DOCKER=1 to opt into Docker installation.
Set SENTINEL_ALLOW_UNVERIFIED_IMAGE=1 only for a trusted URL without a hash.
EOF
}

need_arg() {
  local option="$1"
  local value="${2-}"
  [[ -n "$value" && "$value" != --* ]] || die "$option requires a value"
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --public-host) need_arg "$1" "${2-}"; SENTINEL_PUBLIC_HOST="$2"; shift 2 ;;
    --image-tar) need_arg "$1" "${2-}"; SENTINEL_IMAGE_TAR="$2"; shift 2 ;;
    --image-sha256) need_arg "$1" "${2-}"; SENTINEL_IMAGE_SHA256="$2"; shift 2 ;;
    --image) need_arg "$1" "${2-}"; SENTINEL_IMAGE="$2"; shift 2 ;;
    --home) need_arg "$1" "${2-}"; SENTINEL_HOME="$2"; shift 2 ;;
    --oci-dir) need_arg "$1" "${2-}"; SENTINEL_OCI_DIR="$2"; shift 2 ;;
    --port) need_arg "$1" "${2-}"; SENTINEL_PORT="$2"; shift 2 ;;
    --force) SENTINEL_FORCE=1; shift ;;
    -h|--help) usage; exit 0 ;;
    *) die "unknown option: $1" ;;
  esac
done

validate_single_line() {
  local name="$1" value="$2"
  [[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] ||
    die "$name must not contain line breaks"
}

validate_inputs() {
  validate_single_line SENTINEL_PUBLIC_HOST "$SENTINEL_PUBLIC_HOST"
  validate_single_line SENTINEL_IMAGE "$SENTINEL_IMAGE"
  validate_single_line SENTINEL_IMAGE_TAR "$SENTINEL_IMAGE_TAR"
  validate_single_line SENTINEL_HOME "$SENTINEL_HOME"
  validate_single_line SENTINEL_OCI_DIR "$SENTINEL_OCI_DIR"
  validate_single_line SENTINEL_OCI_CONFIG_PATH "${SENTINEL_OCI_CONFIG_PATH:-}"

  [[ "$SENTINEL_PORT" =~ ^[0-9]+$ ]] ||
    die "SENTINEL_PORT must be an integer from 1 to 65535"
  (( 10#$SENTINEL_PORT >= 1 && 10#$SENTINEL_PORT <= 65535 )) ||
    die "SENTINEL_PORT must be an integer from 1 to 65535"
  [[ "$SENTINEL_HEALTH_TIMEOUT" =~ ^[0-9]+$ ]] ||
    die "SENTINEL_HEALTH_TIMEOUT must be a positive integer"

  [[ -n "$SENTINEL_PUBLIC_HOST" ]] ||
    die "SENTINEL_PUBLIC_HOST is required for unattended installs"
  [[ ${#SENTINEL_PUBLIC_HOST} -le 253 ]] ||
    die "SENTINEL_PUBLIC_HOST is too long"
  [[ "$SENTINEL_PUBLIC_HOST" != "0.0.0.0" ]] ||
    die "SENTINEL_PUBLIC_HOST cannot be 0.0.0.0"
  [[ "$SENTINEL_PUBLIC_HOST" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] ||
    die "SENTINEL_PUBLIC_HOST must be a hostname or IPv4 address without scheme, path, or port"
  [[ "$SENTINEL_PUBLIC_HOST" != *..* &&
     "$SENTINEL_PUBLIC_HOST" != *.-* &&
     "$SENTINEL_PUBLIC_HOST" != *-.*
  ]] || die "SENTINEL_PUBLIC_HOST contains an invalid DNS label"
  local label host_labels
  IFS='.' read -r -a host_labels <<<"$SENTINEL_PUBLIC_HOST"
  for label in "${host_labels[@]}"; do
    [[ ${#label} -le 63 ]] ||
      die "SENTINEL_PUBLIC_HOST contains a DNS label longer than 63 characters"
  done

  [[ "$SENTINEL_IMAGE" =~ ^[A-Za-z0-9][A-Za-z0-9._/:@-]*$ ]] ||
    die "SENTINEL_IMAGE contains unsupported characters"
  if [[ -n "$SENTINEL_IMAGE_SHA256" ]]; then
    SENTINEL_IMAGE_SHA256="$(
      printf '%s' "$SENTINEL_IMAGE_SHA256" | tr '[:upper:]' '[:lower:]'
    )"
    [[ "$SENTINEL_IMAGE_SHA256" =~ ^[a-f0-9]{64}$ ]] ||
      die "SENTINEL_IMAGE_SHA256 must be 64 hexadecimal characters"
  fi
}

is_nas_appliance() {
  [[ -d /usr/syno || -d /usr/trim || -d /etc/ugreen ]] && return 0
  if [[ -f /etc/os-release ]] &&
    tr '[:upper:]' '[:lower:]' < /etc/os-release |
      awk '/ugreen|synology/{found=1} END{exit !found}'
  then
    return 0
  fi
  return 1
}

ensure_docker() {
  if command -v docker >/dev/null 2>&1; then
    docker compose version >/dev/null 2>&1 ||
      die "Docker is installed but 'docker compose' is unavailable"
    docker info >/dev/null 2>&1 ||
      die "Docker is installed but the daemon is unavailable or permission is denied"
    return
  fi

  if is_nas_appliance; then
    die "Docker is not on PATH. Install/start it through the NAS Container Manager"
  fi

  case "$(uname -s 2>/dev/null || printf unknown)" in
    Darwin)
      die "Install and start Docker Desktop from $DOCKER_DESKTOP_URL, then retry"
      ;;
    Linux)
      log "Docker is missing. Manual install:"
      log "  curl -fsSL $GET_DOCKER_URL | sudo sh"
      if [[ "$SENTINEL_INSTALL_DOCKER" != "1" ]]; then
        die "retry after installation, or explicitly set SENTINEL_INSTALL_DOCKER=1"
      fi
      command -v curl >/dev/null 2>&1 || die "curl is required to install Docker"
      if [[ "$(id -u)" -eq 0 ]]; then
        curl -fsSL "$GET_DOCKER_URL" | sh
      else
        command -v sudo >/dev/null 2>&1 ||
          die "automatic Docker installation requires root or sudo"
        curl -fsSL "$GET_DOCKER_URL" | sudo sh
      fi
      command -v docker >/dev/null 2>&1 ||
        die "Docker installation did not provide the docker command"
      docker compose version >/dev/null 2>&1 ||
        die "Docker was installed without Compose"
      docker info >/dev/null 2>&1 ||
        die "Docker was installed but is not usable yet; start it and refresh group membership"
      ;;
    *)
      die "unsupported OS; install Docker manually"
      ;;
  esac
}

sha256_file() {
  local file="$1"
  if command -v sha256sum >/dev/null 2>&1; then
    sha256sum "$file" | awk '{print tolower($1)}'
  elif command -v shasum >/dev/null 2>&1; then
    shasum -a 256 "$file" | awk '{print tolower($1)}'
  elif command -v openssl >/dev/null 2>&1; then
    openssl dgst -sha256 "$file" | awk '{print tolower($NF)}'
  else
    die "sha256sum, shasum, or openssl is required to verify the image"
  fi
}

download_or_locate_tar() {
  local source="$1"
  if [[ "$source" == http://* || "$source" == https://* ]]; then
    [[ -n "$SENTINEL_IMAGE_SHA256" || "$SENTINEL_ALLOW_UNVERIFIED_IMAGE" == "1" ]] ||
      die "SENTINEL_IMAGE_SHA256 is required for a remote image tar"
    command -v curl >/dev/null 2>&1 || die "curl is required to download the image"
    local tmp
    tmp="$(mktemp "${TMPDIR:-/tmp}/sentinel-image.XXXXXX.tar")"
    TEMP_FILES+=("$tmp")
    log "Downloading image tar" >&2
    curl --fail --location --retry 3 \
      --connect-timeout 20 --output "$tmp" "$source"
    IMAGE_TAR_PATH="$tmp"
  else
    [[ -f "$source" ]] || die "image tar not found: $source"
    IMAGE_TAR_PATH="$source"
  fi
}

verify_tar() {
  local file="$1"
  [[ -s "$file" ]] || die "image tar is empty"
  if [[ -n "$SENTINEL_IMAGE_SHA256" ]]; then
    local actual
    actual="$(sha256_file "$file")"
    [[ "$actual" == "$SENTINEL_IMAGE_SHA256" ]] ||
      die "image SHA-256 mismatch (expected $SENTINEL_IMAGE_SHA256, got $actual)"
    log "Image SHA-256 verified"
  elif [[ "$SENTINEL_ALLOW_UNVERIFIED_IMAGE" == "1" ]]; then
    log "WARNING: loading an unverified image tar"
  fi
}

image_exists() {
  docker image inspect "$SENTINEL_IMAGE" >/dev/null 2>&1
}

load_image() {
  local file="$1" output loaded_refs loaded_ref loaded_count
  log "Loading image from $file"
  output="$(docker load --input "$file")"
  printf '%s\n' "$output"

  loaded_refs="$(
    printf '%s\n' "$output" |
      awk '/^Loaded image: /{sub(/^Loaded image: /,""); print}
           /^Loaded image ID: /{sub(/^Loaded image ID: /,""); print}'
  )"

  if image_exists && [[ "$output" == *"Loaded image: $SENTINEL_IMAGE"* ]]; then
    return
  fi
  [[ -n "$loaded_refs" ]] ||
    die "docker load did not report a loaded image reference"
  loaded_count="$(printf '%s\n' "$loaded_refs" | awk 'NF{count++} END{print count+0}')"
  [[ "$loaded_count" -eq 1 ]] ||
    die "image archive contains multiple image references; set SENTINEL_IMAGE to a tag contained in the archive"
  loaded_ref="$loaded_refs"
  docker tag "$loaded_ref" "$SENTINEL_IMAGE"
  image_exists || die "failed to tag loaded image as $SENTINEL_IMAGE"
}

normalize_arch() {
  local arch
  arch="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
  case "$arch" in
    x86_64|amd64) printf amd64 ;;
    aarch64|arm64) printf arm64 ;;
    armv7l|arm) printf arm ;;
    *) printf '%s' "$arch" ;;
  esac
}

validate_architecture() {
  local server image
  server="$(normalize_arch "$(docker info --format '{{.Architecture}}')")"
  image="$(normalize_arch "$(docker image inspect "$SENTINEL_IMAGE" --format '{{.Architecture}}')")"
  [[ -n "$server" && -n "$image" ]] || die "could not determine Docker/image architecture"
  [[ "$server" == "$image" ]] ||
    die "image architecture $image does not match Docker host $server; obtain a compatible image"
}

prompt_if_needed() {
  if [[ -z "$SENTINEL_PUBLIC_HOST" ]] && is_tty; then
    read -r -p "Public host clients will open [127.0.0.1]: " SENTINEL_PUBLIC_HOST
    SENTINEL_PUBLIC_HOST="${SENTINEL_PUBLIC_HOST:-127.0.0.1}"
  fi
  if [[ -z "$SENTINEL_IMAGE_TAR" ]] && ! image_exists && is_tty; then
    read -r -p "Path or URL of docker save tar: " SENTINEL_IMAGE_TAR
  fi
  if [[ -z "$SENTINEL_OCI_DIR" && -d "$HOME/.oci" ]] && is_tty; then
    local reply
    read -r -p "Mount $HOME/.oci read-only? [Y/n]: " reply
    [[ "${reply:-Y}" =~ ^[Yy]([Ee][Ss])?$ ]] && SENTINEL_OCI_DIR="$HOME/.oci"
  fi
}

read_existing_env() {
  local file="$1" name="$2"
  [[ -f "$file" ]] || return 0
  awk -v key="$name" 'index($0,key"=")==1 {sub("^[^=]*=",""); value=$0} END{print value}' "$file"
}

validate_oci() {
  [[ -n "$SENTINEL_OCI_DIR" ]] || return
  [[ -d "$SENTINEL_OCI_DIR" ]] || die "SENTINEL_OCI_DIR is not a directory"
  [[ -r "$SENTINEL_OCI_DIR/config" ]] ||
    die "$SENTINEL_OCI_DIR/config is missing or unreadable"

  local key_file host_key
  key_file="$(
    awk -F= '
      /^[[:space:]]*key_file[[:space:]]*=/ {
        sub(/^[^=]*=[[:space:]]*/, ""); value=$0
      }
      END {gsub(/[[:space:]]+$/, "", value); print value}
    ' "$SENTINEL_OCI_DIR/config"
  )"
  [[ -n "$key_file" ]] || die "OCI config has no key_file"
  [[ "$key_file" == /home/node/.oci/* ]] ||
    die "OCI key_file must use /home/node/.oci/<key>.pem inside the container"
  host_key="$SENTINEL_OCI_DIR/${key_file#/home/node/.oci/}"
  [[ -r "$host_key" ]] || die "OCI key is missing or unreadable: $host_key"
}

validate_env_value() {
  local name="$1" value="$2"
  validate_single_line "$name" "$value"
}

write_requested_env() {
  local dest="$1" name value
  {
    printf '# Generated by Sentinel installer. Firewall-internal only.\n'
    printf 'SENTINEL_HOST=0.0.0.0\n'
    printf 'SENTINEL_PUBLIC_HOST=%s\n' "$SENTINEL_PUBLIC_HOST"
    printf 'SENTINEL_PORT=%s\n' "$SENTINEL_PORT"
    printf 'SENTINEL_MODE=production\n'
    printf 'SENTINEL_DATA_DIR=/data\n'
    printf 'SENTINEL_IMAGE=%s\n' "$SENTINEL_IMAGE"
    for name in \
      SENTINEL_AGENT_INVOCATIONS SENTINEL_REMOTE_MUTATIONS \
      SENTINEL_SCHEDULE_CREATION SENTINEL_DESTRUCTIVE_OPS \
      SENTINEL_AIDP_REGION SENTINEL_AIDP_DATA_LAKE_OCID \
      SENTINEL_AIDP_API_VERSION SENTINEL_AIDP_WORKBENCH_API_VERSION \
      SENTINEL_AIDP_WORKSPACE_KEY SENTINEL_AIDP_REGISTRY_OPS_WORKSPACE_KEY \
      SENTINEL_AIDP_API_BASE_DOMAIN SENTINEL_AIDP_REQUEST_TIMEOUT_MS \
      SENTINEL_OCI_PROFILE SENTINEL_OCI_COMPARTMENT_OCID \
      SENTINEL_REGISTRY_BEARER_TOKENS_FILE
    do
      value="${!name-}"
      if [[ -n "$value" ]]; then
        validate_env_value "$name" "$value"
        printf '%s=%s\n' "$name" "$value"
      fi
    done
    if [[ -n "$SENTINEL_OCI_DIR" ]]; then
      printf 'SENTINEL_OCI_DIR=%s\n' "$SENTINEL_OCI_DIR"
      printf 'SENTINEL_OCI_CONFIG_PATH=%s\n' \
        "${SENTINEL_OCI_CONFIG_PATH:-/home/node/.oci/config}"
    fi
  } >"$dest"
}

merge_env() {
  local current="$1" requested="$2" output="$3"
  awk -F= '
    NR==FNR {
      if ($0 ~ /^[A-Za-z_][A-Za-z0-9_]*=/) {
        key=$1; replacement[key]=$0; order[++count]=key
      } else if ($0 ~ /^#/) {
        header[++headers]=$0
      }
      next
    }
    {
      if ($0 ~ /^[A-Za-z_][A-Za-z0-9_]*=/) {
        key=$1
        if (key in replacement) {
          print replacement[key]; used[key]=1; next
        }
      }
      print
    }
    END {
      for (i=1; i<=count; i++) {
        key=order[i]
        if (!(key in used)) print replacement[key]
      }
    }
  ' "$requested" "$current" >"$output"
}

yaml_single_quote() {
  printf "'%s'" "$(printf '%s' "$1" | sed "s/'/''/g")"
}

write_compose() {
  local dest="$1" image host oci
  image="$(yaml_single_quote "$SENTINEL_IMAGE")"
  host="$(yaml_single_quote "$SENTINEL_PUBLIC_HOST")"
  cat >"$dest" <<EOF
services:
  sentinel:
    image: $image
    ports:
      - "${SENTINEL_PORT}:${SENTINEL_PORT}"
    environment:
      SENTINEL_HOST: "0.0.0.0"
      SENTINEL_PUBLIC_HOST: $host
      SENTINEL_PORT: "${SENTINEL_PORT}"
      SENTINEL_MODE: production
      SENTINEL_DATA_DIR: /data
    env_file: .env
    volumes:
      - sentinel-data:/data
EOF
  if [[ -n "$SENTINEL_OCI_DIR" ]]; then
    oci="$(yaml_single_quote "$SENTINEL_OCI_DIR")"
    cat >>"$dest" <<EOF
      - type: bind
        source: $oci
        target: /home/node/.oci
        read_only: true
EOF
  fi
  cat >>"$dest" <<'EOF'

volumes:
  sentinel-data:
EOF
}

install_files() {
  mkdir -p "$SENTINEL_HOME"
  local env_file="$SENTINEL_HOME/.env"
  local compose_file="$SENTINEL_HOME/docker-compose.yml"
  local requested merged timestamp
  requested="$(mktemp "${TMPDIR:-/tmp}/sentinel-env.XXXXXX")"
  merged="$(mktemp "${TMPDIR:-/tmp}/sentinel-env-merged.XXXXXX")"
  TEMP_FILES+=("$requested" "$merged")
  write_requested_env "$requested"

  if [[ -f "$env_file" && "$SENTINEL_FORCE" != "1" ]]; then
    merge_env "$env_file" "$requested" "$merged"
  else
    cp "$requested" "$merged"
  fi

  timestamp="$(date +%Y%m%d%H%M%S)"
  [[ ! -f "$env_file" ]] || cp "$env_file" "$env_file.backup.$timestamp"
  [[ ! -f "$compose_file" ]] || cp "$compose_file" "$compose_file.backup.$timestamp"
  chmod 600 "$merged"
  mv "$merged" "$env_file"
  write_compose "$compose_file"
}

show_logs_and_die() {
  local message="$1"
  (
    cd "$SENTINEL_HOME"
    docker compose ps || true
    docker compose logs --no-color --tail 100 sentinel || true
  ) >&2
  die "$message"
}

wait_for_health() {
  local id deadline status
  (
    cd "$SENTINEL_HOME"
    id="$(docker compose ps -q sentinel)"
    [[ -n "$id" ]] || exit 2
    deadline=$((SECONDS + SENTINEL_HEALTH_TIMEOUT))
    while (( SECONDS < deadline )); do
      status="$(docker inspect --format \
        '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \
        "$id")"
      case "$status" in
        healthy|running) exit 0 ;;
        unhealthy|exited|dead) exit 3 ;;
      esac
      sleep 2
    done
    exit 4
  ) || show_logs_and_die "Sentinel did not become healthy"
}

ensure_docker
prompt_if_needed

if [[ -z "$SENTINEL_OCI_DIR" && -f "$SENTINEL_HOME/.env" ]]; then
  SENTINEL_OCI_DIR="$(read_existing_env "$SENTINEL_HOME/.env" SENTINEL_OCI_DIR)"
fi
validate_inputs
validate_oci

# Supplying a tar means update explicitly, even if the tag already exists.
if [[ -n "$SENTINEL_IMAGE_TAR" ]]; then
  download_or_locate_tar "$SENTINEL_IMAGE_TAR"
  verify_tar "$IMAGE_TAR_PATH"
  load_image "$IMAGE_TAR_PATH"
elif ! image_exists; then
  die "image $SENTINEL_IMAGE is absent; set SENTINEL_IMAGE_TAR or run docker load -i <tar>"
fi

validate_architecture
install_files

log "Starting Sentinel in $SENTINEL_HOME"
(
  cd "$SENTINEL_HOME"
  docker compose up --detach
) || show_logs_and_die "docker compose up failed"
wait_for_health

log ""
log "Sentinel is healthy: http://${SENTINEL_PUBLIC_HOST}:${SENTINEL_PORT}"
log "Firewall-internal only: anyone who loads GET / receives a session cookie."
