#!/usr/bin/env bash
# BatchNepal bp-gateway installer
#
# First install — no license key needed, activates on the free Community
# plan automatically (upgrade later from the Frappe app's Billing page):
#   curl -fsSL https://get.batchprojects.com/install.sh | bash -s -- \
#     --frappe https://yoursite.example.com --domain gateway.yoursite.com
#
# Already have a paid license key? Pass it and skip straight to that tier:
#   curl -fsSL https://get.batchprojects.com/install.sh | bash -s -- \
#     --key BP-XXXX-XXXX-XXXX --frappe https://yoursite.example.com --domain gateway.yoursite.com
#
# --frappe and --domain are both optional: omitting --frappe on a same-VPS
# install prompts to auto-detect a local Frappe instance; omitting --domain
# gets you a free working one at <yoursite>.erpnext-nepal.com, provisioned
# automatically by the license server.
#
# Update an existing install to the newest gateway version compatible with
# the batch_projects currently running at --frappe:
#   curl -fsSL https://get.batchprojects.com/install.sh | bash -s -- --update
set -euo pipefail

# ── Config ────────────────────────────────────────────────────────────────────

CDN_BASE="https://get.batchprojects.com"
BOOTSTRAP_URL="https://platform.batchprojects.com/api/licenses/bootstrap/"
REGISTER_URL="https://platform.batchprojects.com/api/licenses/register/"
INSTALL_DIR="/opt/bp-gateway"
RED="\033[0;31m"; GREEN="\033[0;32m"; YELLOW="\033[1;33m"; CYAN="\033[0;36m"; NC="\033[0m"

# Ed25519 public key for channels/stable.json's signature. The private half
# (release-signing-private.pem) never leaves whoever runs scripts/release.sh
# — it is NOT in this repo, keep it in a secrets manager / offline, and set
# RELEASE_SIGNING_KEY_PATH to wherever you store it when releasing.
RELEASE_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA06M143e5C45jjSylEsw+K0MgRk8Qbw2DEci/ZMicXSs=
-----END PUBLIC KEY-----"

# ── Args ──────────────────────────────────────────────────────────────────────

LICENSE_KEY=""
FRAPPE_URL=""
DOMAIN=""
UPDATE_MODE=0

while [[ $# -gt 0 ]]; do
  case "$1" in
    --key)    LICENSE_KEY="$2"; shift 2 ;;
    --frappe) FRAPPE_URL="$2";  shift 2 ;;
    --domain) DOMAIN="$2";      shift 2 ;;
    --update) UPDATE_MODE=1;    shift ;;
    *) echo -e "${RED}Unknown option: $1${NC}"; exit 1 ;;
  esac
done

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

# All four write to stderr, not stdout — resolve_gateway_version() below
# returns its actual result via stdout (captured with `$(...)`), so any
# status/progress output written to stdout there would silently corrupt
# that capture (confirmed the hard way: without >&2 here, `read -r VERSION
# DIGEST <<<"$(resolve_gateway_version ...)"` picked up these messages
# instead of the real values).
info()    { echo -e "${CYAN}  →${NC} $*" >&2; }
success() { echo -e "${GREEN}  ✓${NC} $*" >&2; }
warn()    { echo -e "${YELLOW}  !${NC} $*" >&2; }
die()     { echo -e "${RED}  ✗ $*${NC}" >&2; exit 1; }

# curl | bash means stdin IS install.sh's own source — by the time bash
# reaches a `read` here, stdin is already at EOF (or, worse, still holding
# buffered script bytes bash hasn't consumed yet, which `read` can end up
# eating instead of terminal input — reproduced this directly, it's not
# hypothetical). Every prompt below reads from /dev/tty instead, which is
# the real keyboard regardless of how stdin is occupied. Falls back to
# plain stdin if /dev/tty genuinely isn't available (e.g. install.sh run
# as a local file with input piped from somewhere else on purpose), and
# fails safely to an empty answer with no tty at all (CI, cron).
read_tty()        { local p="$1" v="$2"; { read -rp  "$p" "$v" </dev/tty; } 2>/dev/null || read -rp  "$p" "$v" 2>/dev/null || true; }
read_tty_secret() { local p="$1" v="$2"; { read -rsp "$p" "$v" </dev/tty; } 2>/dev/null || read -rsp "$p" "$v" 2>/dev/null || true; }

prompt_if_empty() {
  local var="$1" label="$2" secret="${3:-}"
  if [[ -z "${!var}" ]]; then
    if [[ "$secret" == "secret" ]]; then
      read_tty_secret "  $label: " "$var" && echo
    else
      read_tty "  $label: " "$var"
    fi
  fi
}

# install.sh's own curl calls run on the bare host, never inside a container
# — host.docker.internal only resolves *inside* a container (this compose
# stack maps it there, see deploy/docker-compose.yml's extra_hosts). This is
# only for install.sh's own preflight checks; $FRAPPE_URL itself (sent to
# bp-license, baked into gateway.yaml for the containerized gateway to read)
# keeps whatever value is correct for the container.
host_reachable_url() {
  echo "${1/host.docker.internal/127.0.0.1}"
}

# Probes for a Frappe instance on this same machine and reports how it looks
# reachable (Docker vs. bare bench — informational only, see the note at the
# call site on why the networking fix doesn't actually depend on which).
# Never used to silently decide anything — the call site always confirms.
detect_same_vps_frappe() {
  local probe_url="http://127.0.0.1:8000"
  curl -fsS --max-time 3 "$probe_url/api/method/ping" 2>/dev/null | grep -q "pong" || return 1

  local how="a bare bench"
  if command -v docker &>/dev/null && docker ps --format '{{.Ports}}' 2>/dev/null | grep -q ":8000->"; then
    how="a Docker container"
  fi
  info "Detected a Frappe instance on this machine (via $how, port 8000)."
  echo "$probe_url"
}

# Verifies a detached signature over $1 (data file) against $2 (.sig file),
# using RELEASE_PUBLIC_KEY. Fallback chain chosen for what's actually present
# on a fresh minimal VPS — minisign/signify aren't standard there, python3
# and openssl (1.1.1+, which has had Ed25519 since 2018) both essentially
# always are. Dies rather than proceeding unverified if neither works.
verify_signature() {
  local data_file="$1" sig_file="$2"
  if [[ "$RELEASE_PUBLIC_KEY" == "REPLACE_WITH_REAL_RELEASE_PUBLIC_KEY" ]]; then
    die "installer's release public key is still a placeholder — this build of install.sh isn't ready for distribution"
  fi
  if command -v python3 &>/dev/null && python3 -c "import cryptography" &>/dev/null; then
    python3 - "$data_file" "$sig_file" <<PYEOF && return 0
import sys
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from cryptography.exceptions import InvalidSignature

pub = load_pem_public_key(b"""$RELEASE_PUBLIC_KEY""")
data = open(sys.argv[1], "rb").read()
sig = open(sys.argv[2], "rb").read()
try:
    pub.verify(sig, data)
except InvalidSignature:
    sys.exit(1)
PYEOF
    return 1
  fi
  if command -v openssl &>/dev/null; then
    local pubkey_file
    pubkey_file="$(mktemp)"
    printf '%s\n' "$RELEASE_PUBLIC_KEY" > "$pubkey_file"
    if openssl pkeyutl -verify -rawin -pubin -inkey "$pubkey_file" -sigfile "$sig_file" -in "$data_file" &>/dev/null; then
      rm -f "$pubkey_file"; return 0
    fi
    rm -f "$pubkey_file"; return 1
  fi
  die "neither python3 (with the 'cryptography' package) nor openssl is available to verify the release manifest's signature — refusing to proceed unverified"
}

# Detects the installed batch_projects version + its declared gateway_min_version
# via the unauthenticated get_session_info endpoint, and resolves the newest
# gateway release from channels/stable.json whose compat range covers it.
# Prints "VERSION IMAGE_DIGEST" on stdout.
resolve_gateway_version() {
  local frappe_url="$1"
  info "Checking batch_projects version at $frappe_url..."
  local session_info app_version
  session_info="$(curl -fsSL "$frappe_url/api/method/batch_projects.api.board.get_session_info" 2>/dev/null)" \
    || die "could not reach $frappe_url — check --frappe"
  app_version="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['message']['app_version'])" "$session_info" 2>/dev/null)" \
    || die "unexpected response from $frappe_url/api/method/batch_projects.api.board.get_session_info"
  info "batch_projects version: $app_version"

  info "Fetching gateway release manifest..."
  local manifest_file sig_file
  manifest_file="$(mktemp)"; sig_file="$(mktemp)"
  curl -fsSL "$CDN_BASE/channels/stable.json" -o "$manifest_file" || die "could not fetch release manifest from $CDN_BASE"
  curl -fsSL "$CDN_BASE/channels/stable.json.sig" -o "$sig_file" || die "could not fetch manifest signature from $CDN_BASE"

  verify_signature "$manifest_file" "$sig_file" || die "release manifest signature verification FAILED — refusing to trust it (possible tampering, or a corrupted download — try again)"
  success "manifest signature verified"

  local resolved
  resolved="$(python3 - "$manifest_file" "$app_version" <<'PYEOF'
import json, sys

def parse(v):
    parts = v.split(".")
    out = []
    for p in parts[:3]:
        out.append(None if p == "x" else int(p))
    while len(out) < 3:
        out.append(None)
    return out

def in_range(v, lo, hi):
    v, lo, hi = parse(v), parse(lo), parse(hi)
    for i in range(3):
        if lo[i] is not None and v[i] < lo[i]:
            return False
        if lo[i] is not None and v[i] > lo[i]:
            break
    for i in range(3):
        if hi[i] is None:  # "x" — matches any value from here down
            return True
        if v[i] > hi[i]:
            return False
        if v[i] < hi[i]:
            return True
    return True

manifest_path, app_version = sys.argv[1], sys.argv[2]
with open(manifest_path) as f:
    data = json.load(f)

candidates = [r for r in data["releases"] if in_range(app_version, r["batch_projects_min"], r["batch_projects_max"])]
if not candidates:
    sys.exit(1)
best = max(candidates, key=lambda r: [int(p) for p in r["version"].split(".")])
print(best["version"], best["image_digest"])
PYEOF
)" || die "no gateway release is compatible with batch_projects $app_version — the manifest at $CDN_BASE/channels/stable.json has no matching batch_projects_min/max range. Upgrade batch_projects, or check for a newer install.sh."

  rm -f "$manifest_file" "$sig_file"
  echo "$resolved"
}

# ── --update mode ─────────────────────────────────────────────────────────────

if [[ "$UPDATE_MODE" == "1" ]]; then
  echo
  echo -e "${CYAN}  bp-gateway updater${NC}"
  echo -e "  ────────────────────"
  echo
  [[ -d "$INSTALL_DIR" ]] || die "$INSTALL_DIR not found — this doesn't look like an existing install"
  cd "$INSTALL_DIR"
  [[ -f gateway.yaml ]] || die "gateway.yaml missing in $INSTALL_DIR"

  FRAPPE_URL="$(python3 -c "import yaml; print(yaml.safe_load(open('gateway.yaml'))['frappe']['base_url'])")" \
    || die "could not read frappe.base_url from gateway.yaml"

  # Domain change (e.g. moving off an auto-provisioned erpnext-nepal.com one
  # onto a real domain) is independent of the version-update logic below — handled
  # here so it still applies even when the gateway is already on the newest
  # compatible version and that logic would otherwise exit early.
  if [[ -n "$DOMAIN" ]]; then
    # `|| true`: a missing Caddyfile would otherwise make this unguarded
    # assignment fail and, under `set -e`, abort the whole update.
    CURRENT_DOMAIN="$([[ -f Caddyfile ]] && awk 'NR==1{print $1}' Caddyfile)" || true
    if [[ -n "$CURRENT_DOMAIN" && "$DOMAIN" != "$CURRENT_DOMAIN" ]]; then
      info "Updating domain $CURRENT_DOMAIN → $DOMAIN..."
      cat > Caddyfile <<EOF
$DOMAIN {
    reverse_proxy gateway:8001
}
EOF
      docker compose restart caddy
      success "Caddyfile updated, Caddy restarted — DNS must point $DOMAIN at this server for TLS to work"
    fi
  fi

  # `|| die` here, not just inside resolve_gateway_version — that function's
  # own die() runs inside this $(...) subshell, so its `exit 1` only ends the
  # subshell; without this check the script would carry on with empty
  # NEW_VERSION/NEW_DIGEST instead of actually stopping (confirmed the hard
  # way in testing: a tampered manifest was correctly rejected internally
  # but the script kept going anyway).
  RESOLVE_OUT="$(resolve_gateway_version "$(host_reachable_url "$FRAPPE_URL")")" || die "could not resolve a compatible gateway version"
  read -r NEW_VERSION NEW_DIGEST <<<"$RESOLVE_OUT"
  CURRENT_TAG="$(grep -oP '(?<=GATEWAY_IMAGE_TAG=).*' .env 2>/dev/null || echo "unknown")"

  if [[ "$NEW_VERSION" == "$CURRENT_TAG" ]]; then
    success "already on the newest compatible version ($CURRENT_TAG)"
    exit 0
  fi

  info "Updating $CURRENT_TAG → $NEW_VERSION..."
  sed -i "s/^GATEWAY_IMAGE_TAG=.*/GATEWAY_IMAGE_TAG=$NEW_VERSION/" .env 2>/dev/null \
    || echo "GATEWAY_IMAGE_TAG=$NEW_VERSION" >> .env
  docker compose pull
  docker compose up -d
  success "updated to bp-gateway $NEW_VERSION ($NEW_DIGEST)"
  exit 0
fi

# ── Banner ────────────────────────────────────────────────────────────────────

echo
echo -e "${CYAN}  BatchNepal bp-gateway installer${NC}"
echo -e "  ──────────────────────────────────"
echo

# ── Collect inputs ────────────────────────────────────────────────────────────

# No prompt for LICENSE_KEY: omitted entirely means the silent Community
# activation path below (register()) — pass --key only if you already have
# a paid one and want to skip straight to that tier.

# Same-VPS auto-detect: only when --frappe wasn't given explicitly (an
# explicit --frappe always wins outright — covers Frappe Cloud and
# separate-VPS installs, where nothing local is relevant). Detected, shown,
# and confirmed — never silently assumed.
if [[ -z "$FRAPPE_URL" ]]; then
  if DETECTED_URL="$(detect_same_vps_frappe)"; then
    read_tty "  Use it for this gateway install? [Y/n] " USE_DETECTED
    if [[ ! "$USE_DETECTED" =~ ^[Nn]$ ]]; then
      # The gateway runs in a container; 127.0.0.1 there means the container
      # itself, not the host. host.docker.internal is what the container
      # needs to reach out to the host (mapped in deploy/docker-compose.yml).
      FRAPPE_URL="http://host.docker.internal:8000"
      success "Using $FRAPPE_URL (same-VPS install)"
    fi
  fi
fi
prompt_if_empty FRAPPE_URL "ERPNext URL (e.g. https://yoursite.example.com)"
# DOMAIN is deliberately never prompted for — leftover from before the
# erpnext-nepal.com auto-provisioning flow existed. An empty DOMAIN here
# isn't a missing required value; NEED_AUTO_DOMAIN below already handles
# it silently and automatically, exactly as documented at the top of this
# file. Prompting for it anyway blocked that behavior and looked
# indistinguishable from the installer hanging (no prompt text made it
# obvious it was waiting on input, confirmed the hard way on a real run).

LICENSE_KEY="${LICENSE_KEY^^}"   # normalise to uppercase
FRAPPE_URL="${FRAPPE_URL%/}"

[[ "$FRAPPE_URL" =~ ^https?:// ]] || die "FRAPPE_URL must start with http:// or https://"

# No --domain: the license server provisions a free <yoursite>.erpnext-nepal.com
# subdomain for us below (need_gateway_domain in the activation call), rather
# than generating one locally — DOMAIN is filled in from that response.
NEED_AUTO_DOMAIN=0
[[ -z "$DOMAIN" ]] && NEED_AUTO_DOMAIN=1

# ── Prerequisites ─────────────────────────────────────────────────────────────

info "Checking prerequisites..."
for bin in curl python3; do
  command -v "$bin" &>/dev/null || die "$bin is required but not found"
done

if ! command -v docker &>/dev/null; then
  warn "Docker not found."
  read_tty "  Install Docker now via get.docker.com? [y/N] " INSTALL_DOCKER
  [[ "$INSTALL_DOCKER" =~ ^[Yy]$ ]] || die "Docker is required — install it and re-run"
  curl -fsSL https://get.docker.com | sh
  if [[ "$EUID" -ne 0 ]]; then
    sudo usermod -aG docker "$USER"
    warn "Added $USER to the docker group. You may need to log out and back in."
  fi
fi
docker compose version &>/dev/null || die "Docker Compose (v2) not found. Upgrade Docker to >= 23."
success "Docker OK"

# ── Resolve a compatible gateway version ─────────────────────────────────────

# See the --update branch above for why `|| die` is needed here too, not
# just inside resolve_gateway_version itself.
info "Resolving a compatible gateway version..."
RESOLVE_OUT="$(resolve_gateway_version "$FRAPPE_URL")" || die "could not resolve a compatible gateway version"
read -r RESOLVED_VERSION RESOLVED_DIGEST <<<"$RESOLVE_OUT"
success "resolved gateway version $RESOLVED_VERSION for this batch_projects install"

# ── Activate: exchange a license key (or nothing) for gateway.yaml + secrets ─

if [[ -n "$LICENSE_KEY" ]]; then
  info "Validating license key with BatchNepal..."
  RESPONSE=$(curl -fsSL -X POST "$BOOTSTRAP_URL" \
    -H "Content-Type: application/json" \
    -d "{\"license_key\":\"$LICENSE_KEY\",\"frappe_url\":\"$FRAPPE_URL\",\"need_gateway_domain\":$([[ "$NEED_AUTO_DOMAIN" == "1" ]] && echo true || echo false)}" \
    2>&1) || die "Could not reach license server. Check your internet connection."
else
  info "No license key given — activating on the free Community plan..."
  RESPONSE=$(curl -fsSL -X POST "$REGISTER_URL" \
    -H "Content-Type: application/json" \
    -d "{\"frappe_url\":\"$FRAPPE_URL\",\"need_gateway_domain\":$([[ "$NEED_AUTO_DOMAIN" == "1" ]] && echo true || echo false)}" \
    2>&1) || die "Could not reach license server. Check your internet connection."
fi

if echo "$RESPONSE" | grep -q '"error"'; then
  MSG=$(echo "$RESPONSE" | grep -o '"error":"[^"]*"' | cut -d'"' -f4)
  die "License error: ${MSG:-invalid license}"
fi

field() { python3 -c "import json,sys; print(json.loads(sys.argv[1])['$1'])" "$RESPONSE" 2>/dev/null; }

if [[ "$NEED_AUTO_DOMAIN" == "1" ]]; then
  DOMAIN="$(field gateway_domain)" || true
  [[ -n "$DOMAIN" && "$DOMAIN" != "None" ]] \
    || die "Could not provision an automatic domain. Re-run with --domain <your-domain>."
  success "Using auto-provisioned domain: $DOMAIN"
  warn "Point a real domain at this server later with: --update --domain yours.example.com"
fi

GATEWAY_YAML="$(field gateway_yaml)"
FRAPPE_SHARED_SECRET="$(field frappe_shared_secret)"
BRIDGE_BOOTSTRAP_SECRET="$(field bridge_bootstrap_secret)"
SCHEDULER_INGEST_TOKEN="$(field scheduler_ingest_token)"
[[ -n "$GATEWAY_YAML" ]] || die "Unexpected response from license server."

# Only set when the Community path above issued one (the paid/--key path's
# response has no such field at all — the customer already holds their own
# key). `|| true`: field()'s python3 raising KeyError on a genuinely absent
# field must not abort the whole install under set -e. Printed at the end as the
# self-service recovery path for a lost gateway.yaml, since register()
# deliberately won't hand back an already-registered site's secrets to a
# bare frappe_url (see bp-license's register() docstring).
NEW_LICENSE_KEY="$(field license_key)" || true

success "License valid"

# ── Install directory ─────────────────────────────────────────────────────────

info "Setting up $INSTALL_DIR..."
mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR"

echo "$GATEWAY_YAML" > gateway.yaml
chmod 600 gateway.yaml
success "gateway.yaml written"

curl -fsSL "$CDN_BASE/docker-compose.yml" -o docker-compose.yml
echo "GATEWAY_IMAGE_TAG=$RESOLVED_VERSION" > .env
success "docker-compose.yml downloaded, pinned to $RESOLVED_VERSION"

cat > Caddyfile <<EOF
$DOMAIN {
    reverse_proxy gateway:8001
}
EOF
success "Caddyfile written for $DOMAIN"

# ── Prompt for Frappe API credentials ────────────────────────────────────────

echo
echo -e "${YELLOW}  Almost done. The gateway needs a Frappe service account to make API calls.${NC}"
echo -e "  In ERPNext: Settings → Users → your service account → API Access → Generate Keys"
echo
read_tty        "  Frappe API key:    " FRAPPE_API_KEY
read_tty_secret "  Frappe API secret: " FRAPPE_API_SECRET
echo

python3 - <<PYEOF
import re
with open("gateway.yaml") as f:
    content = f.read()
content = re.sub(r"(api_key:\s*).*",    r"\g<1>\"$FRAPPE_API_KEY\"",    content)
content = re.sub(r"(api_secret:\s*).*", r"\g<1>\"$FRAPPE_API_SECRET\"", content)
with open("gateway.yaml", "w") as f:
    f.write(content)
PYEOF
success "API credentials written to gateway.yaml"

# ── Start ─────────────────────────────────────────────────────────────────────

echo
info "Pulling bp-gateway $RESOLVED_VERSION (this can take a minute on a fresh VPS)..."
docker compose pull
info "Starting bp-gateway..."
docker compose up -d

echo
success "bp-gateway $RESOLVED_VERSION is running!"
echo
echo -e "  ${CYAN}Domain:${NC}  https://$DOMAIN"
echo -e "  ${CYAN}Health:${NC}  https://$DOMAIN/health"
echo -e "  ${CYAN}Logs:${NC}    docker compose -f $INSTALL_DIR/docker-compose.yml logs -f"
echo -e "  ${CYAN}Config:${NC}  $INSTALL_DIR/gateway.yaml"
echo
echo -e "  ${YELLOW}Note:${NC} DNS must point $DOMAIN to this server's IP for TLS to work."
echo

# ── Save-this-key (Community/silent activation only) ─────────────────────────

if [[ -n "$NEW_LICENSE_KEY" ]]; then
  echo
  echo -e "${YELLOW}  Save this license key${NC} — it's how you'd recover this install if"
  echo -e "  ${INSTALL_DIR}/gateway.yaml is ever lost, and it's what support will ask for:"
  echo
  echo -e "    ${CYAN}${NEW_LICENSE_KEY}${NC}"
  echo
fi

# ── Frappe-side configuration (the part the old installer never did) ─────────

echo -e "${YELLOW}  One more step — add these to your Frappe site config:${NC}"
echo
echo -e "  ${CYAN}Self-hosted with shell access${NC} (run on the Frappe box):"
echo "    bench --site <your-site> set-config bp_gateway_shared_secret \"$FRAPPE_SHARED_SECRET\""
echo "    bench --site <your-site> set-config bp_bridge_bootstrap_secret \"$BRIDGE_BOOTSTRAP_SECRET\""
echo "    bench --site <your-site> set-config bp_scheduler_ingest_token \"$SCHEDULER_INGEST_TOKEN\""
echo
echo -e "  ${CYAN}Frappe Cloud${NC} (dashboard → your site → Settings → Site Config → add each, then Update Configuration):"
echo "    bp_gateway_shared_secret    = $FRAPPE_SHARED_SECRET"
echo "    bp_bridge_bootstrap_secret  = $BRIDGE_BOOTSTRAP_SECRET"
echo "    bp_scheduler_ingest_token   = $SCHEDULER_INGEST_TOKEN"
echo
echo -e "  Without this step the gateway will run, but every batch_projects API call will log a warning and skip gateway verification instead of enforcing it."
echo
