#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
INSTALL_ROOT="${ERP_INSTALL_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd -P)}"
CONFIG_FILE="${ERP_CONFIG_FILE:-$INSTALL_ROOT/config/erp.env}"

if [[ ! -r "$CONFIG_FILE" ]]; then
  printf '[ERP Update] configuration not readable: %s\n' "$CONFIG_FILE" >&2
  exit 1
fi

set -a
# shellcheck disable=SC1090
source "$CONFIG_FILE"
set +a

STATE_FILE="${OA_UPDATE_STATE_FILE:-${ERP_STATE_FILE:-$INSTALL_ROOT/state/update-state.json}}"
RUN_DIR="${ERP_RUN_DIR:-$INSTALL_ROOT/run}"
RELEASES_DIR="$INSTALL_ROOT/releases"
CURRENT_LINK="$INSTALL_ROOT/current"
HEALTH_URL="${ERP_HEALTH_URL:-http://127.0.0.1:${SERVER_PORT:-8091}/api/oa/health}"
GITEA_BASE_URL="${OA_UPDATE_GITEA_BASE_URL:-}"
REPOSITORY="${OA_UPDATE_REPOSITORY:-awaioi/ERP}"
CHANNEL="${OA_UPDATE_CHANNEL:-stable}"
TOKEN="${OA_UPDATE_TOKEN:-}"
ALLOW_INSECURE="${OA_UPDATE_ALLOW_INSECURE_HTTP:-false}"
if [[ "${GITEA_BASE_URL%/}" == "http://38.76.196.225:10099" ]]; then
  GITEA_BASE_URL="https://git.awaioi.com"
  ALLOW_INSECURE=false
fi
PUBLIC_KEY_FILE="${ERP_UPDATE_PUBLIC_KEY_FILE:-$INSTALL_ROOT/config/release-public-key.pem}"
REQUIRE_SIGNATURE="${ERP_UPDATE_REQUIRE_SIGNATURE:-true}"
BACKUP_MODE="${ERP_UPDATE_BACKUP_MODE:-none}"
HEALTH_TIMEOUT_SECONDS="${ERP_UPDATE_HEALTH_TIMEOUT_SECONDS:-120}"
HEALTH_POLL_SECONDS="${ERP_UPDATE_HEALTH_POLL_SECONDS:-2}"
TMP_DIR=""
LOCK_FILE="$RUN_DIR/update.lock"
FINAL_STATE=0

say() { printf '[ERP Update] %s\n' "$*"; }

write_state() {
  local phase="$1" progress="$2" message="$3" version="${4:-}" error="${5:-}"
  mkdir -p "$(dirname "$STATE_FILE")"
  python3 - "$STATE_FILE" "$phase" "$progress" "$message" "$version" "$error" <<'PY'
import json, os, sys, tempfile
from datetime import datetime, timezone

path, phase, progress, message, version, error = sys.argv[1:]
payload = {}
try:
    if os.path.getsize(path) <= 64 * 1024:
        with open(path, encoding="utf-8") as handle:
            existing = json.load(handle)
        if isinstance(existing, dict):
            payload = existing
except (OSError, json.JSONDecodeError, TypeError, ValueError):
    pass
payload.update({
    "phase": phase,
    "progress": int(progress),
    "message": message,
    "version": version or None,
    "error": error or None,
    "updatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
})
parent = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(prefix=".update-state-", dir=parent, text=True)
try:
    with os.fdopen(fd, "w", encoding="utf-8") as handle:
        json.dump(payload, handle, ensure_ascii=False, separators=(",", ":"))
        handle.write("\n")
    os.chmod(tmp, 0o640)
    os.replace(tmp, path)
finally:
    if os.path.exists(tmp):
        os.unlink(tmp)
PY
}

cleanup() {
  if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then
    rm -rf "$TMP_DIR"
  fi
}

on_exit() {
  local code=$?
  if [[ "$code" != "0" && "$FINAL_STATE" != "1" ]]; then
    write_state FAILED 0 "更新失败" "${TARGET_VERSION:-}" "更新助手异常退出（code $code）" || true
  fi
  cleanup
}
trap on_exit EXIT

fail() {
  local message="$1"
  write_state FAILED "${2:-0}" "更新失败" "${TARGET_VERSION:-}" "$message" || true
  FINAL_STATE=1
  printf '[ERP Update] ERROR: %s\n' "$message" >&2
  exit 1
}

require_command() {
  command -v "$1" >/dev/null 2>&1 || fail "缺少命令：$1"
}

validate_https_url() {
  local value="$1"
  case "$value" in
    https://*) return 0 ;;
    http://*) [[ "$ALLOW_INSECURE" == "true" || "$ALLOW_INSECURE" == "1" ]] && return 0 ;;
  esac
  fail "更新地址必须使用 HTTPS"
}

acquire_lock_or_reexec() {
  [[ "${ERP_UPDATE_LOCK_HELD:-0}" == "1" ]] && return 0
  mkdir -p "$RUN_DIR"
  command -v python3 >/dev/null 2>&1 || {
    printf '[ERP Update] ERROR: 缺少命令：python3\n' >&2
    exit 1
  }
  exec python3 - "$LOCK_FILE" "$0" "$@" <<'PY'
import fcntl
import os
import shutil
import sys
import time

lock_path, script, *args = sys.argv[1:]
if os.path.isdir(lock_path):
    owner = ""
    try:
        with open(os.path.join(lock_path, "pid"), encoding="ascii") as handle:
            owner = handle.read().strip()
    except OSError:
        pass
    alive = False
    if owner.isdigit():
        try:
            os.kill(int(owner), 0)
            alive = True
        except OSError:
            pass
    try:
        recent = time.time() - os.stat(lock_path).st_mtime < 30
    except OSError:
        recent = False
    if alive or recent:
        raise SystemExit(f"已有更新任务正在执行（PID {owner or 'unknown'}）")
    try:
        shutil.rmtree(lock_path)
    except FileNotFoundError:
        pass

lock = open(lock_path, "a+", encoding="ascii")
try:
    fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
    lock.seek(0)
    owner = lock.read().strip() or "unknown"
    raise SystemExit(f"已有更新任务正在执行（PID {owner}）")
lock.seek(0)
lock.truncate()
lock.write(str(os.getpid()) + "\n")
lock.flush()
os.fchmod(lock.fileno(), 0o640)
os.set_inheritable(lock.fileno(), True)
env = os.environ.copy()
env["ERP_UPDATE_LOCK_HELD"] = "1"
script = os.path.abspath(script)
os.execve(script, [script, *args], env)
PY
}

create_curl_config() {
  CURL_CONFIG="$TMP_DIR/curl.conf"
  {
    printf 'silent\nshow-error\nfail\nlocation\n'
    printf 'connect-timeout = 15\nmax-time = 900\n'
    if [[ "$ALLOW_INSECURE" == "true" || "$ALLOW_INSECURE" == "1" ]]; then
      printf 'proto = "=http,https"\nproto-redir = "=http,https"\n'
    else
      printf 'proto = "=https"\nproto-redir = "=https"\n'
    fi
    if [[ -n "$TOKEN" ]]; then
      [[ "$TOKEN" =~ ^[A-Za-z0-9._-]+$ ]] || fail "Gitea Token 格式无效"
      printf 'header = "Authorization: token %s"\n' "$TOKEN"
    fi
  } > "$CURL_CONFIG"
  chmod 600 "$CURL_CONFIG"
}

download() {
  local url="$1" output="$2"
  validate_https_url "$url"
  curl --config "$CURL_CONFIG" --output "$output" "$url"
}

select_release() {
  local release_json="$1" requested="$2" output="$3"
  python3 - "$release_json" "$requested" "$CHANNEL" > "$output" <<'PY'
import json, re, sys

path, requested, channel = sys.argv[1:]
with open(path, encoding="utf-8") as handle:
    release = json.load(handle)
tag = str(release.get("tag_name") or "").strip()
match = re.fullmatch(r"v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)", tag)
if not match:
    raise SystemExit("invalid release tag")
version = match.group(1)
if requested and requested.removeprefix("v") != version:
    raise SystemExit("requested version is no longer latest")
if release.get("draft"):
    raise SystemExit("latest release is a draft")
if channel.lower() == "stable" and release.get("prerelease"):
    raise SystemExit("prerelease rejected on stable channel")
if channel.lower() == "stable" and "-" in version.split("+", 1)[0]:
    raise SystemExit("prerelease tag rejected on stable channel")
assets = {
    str(item.get("name") or "")
    for item in release.get("assets", [])
    if isinstance(item, dict)
}
names = [f"kaidi-erp-{version}.tar.gz", "SHA256SUMS", "SHA256SUMS.sig"]
if any(name not in assets for name in names):
    raise SystemExit("release assets are incomplete")
print("\t".join([version, tag]))
PY
}

verify_signature_and_checksum() {
  local archive="$1" sums="$2" signature="$3" archive_name="$4"
  write_state VERIFYING 45 "正在校验发布签名" "$TARGET_VERSION"
  if [[ "$REQUIRE_SIGNATURE" == "true" || "$REQUIRE_SIGNATURE" == "1" ]]; then
    [[ -r "$PUBLIC_KEY_FILE" ]] || fail "找不到发布公钥" 45
    require_command openssl
    local openssl_version algorithms
    openssl_version="$(openssl version 2>/dev/null || true)"
    [[ "$openssl_version" =~ ^OpenSSL[[:space:]]3\. ]] \
      || fail "签名验证需要 OpenSSL 3" 45
    algorithms="$(openssl list -public-key-algorithms 2>/dev/null || true)"
    grep -qi ED25519 <<< "$algorithms" \
      || fail "当前 OpenSSL 不支持 Ed25519" 45
    openssl pkeyutl -verify -rawin -pubin -inkey "$PUBLIC_KEY_FILE" \
      -sigfile "$signature" -in "$sums" >/dev/null \
      || fail "Release Ed25519 签名验证失败" 45
  fi

  local expected actual
  expected="$(python3 - "$sums" "$archive_name" <<'PY'
import re, sys
path, wanted = sys.argv[1:]
for line in open(path, encoding="utf-8"):
    match = re.fullmatch(r"([0-9a-fA-F]{64})\s+\*?(.+?)\s*", line)
    if match and match.group(2) == wanted:
        print(match.group(1).lower())
        break
PY
)"
  [[ "$expected" =~ ^[0-9a-f]{64}$ ]] || fail "SHA256SUMS 缺少安装包校验值" 50
  if command -v sha256sum >/dev/null 2>&1; then
    actual="$(sha256sum "$archive" | awk '{print $1}')"
  else
    actual="$(shasum -a 256 "$archive" | awk '{print $1}')"
  fi
  [[ "$actual" == "$expected" ]] || fail "安装包 SHA-256 校验失败" 50
}

extract_archive() {
  local archive="$1" destination="$2"
  python3 - "$archive" <<'PY'
import pathlib, sys, tarfile
with tarfile.open(sys.argv[1], "r:gz") as archive:
    for member in archive.getmembers():
        path = pathlib.PurePosixPath(member.name)
        if path.is_absolute() or ".." in path.parts or member.issym() or member.islnk():
            raise SystemExit(f"unsafe archive member: {member.name}")
PY
  mkdir -p "$destination"
  tar -xzf "$archive" -C "$destination"
}

atomic_switch() {
  local target="$1"
  python3 - "$CURRENT_LINK" "$target" <<'PY'
import os, sys
link, target = sys.argv[1:]
tmp = f"{link}.new-{os.getpid()}"
try:
    os.symlink(target, tmp)
    os.replace(tmp, link)
finally:
    if os.path.lexists(tmp):
        os.unlink(tmp)
PY
}

wait_for_restarted_health() {
  local old_pid="$1" deadline=$((SECONDS + HEALTH_TIMEOUT_SECONDS))
  while (( SECONDS < deadline )); do
    local new_pid=""
    [[ -r "$RUN_DIR/app.pid" ]] && new_pid="$(<"$RUN_DIR/app.pid")"
    if [[ "$new_pid" =~ ^[0-9]+$ && "$new_pid" != "$old_pid" ]] \
      && kill -0 "$new_pid" 2>/dev/null \
      && curl -fsS --connect-timeout 2 --max-time 5 "$HEALTH_URL" >/dev/null 2>&1; then
      return 0
    fi
    sleep "$HEALTH_POLL_SECONDS"
  done
  return 1
}

wait_for_pid_exit() {
  local pid="$1" deadline=$((SECONDS + ${2:-60}))
  while kill -0 "$pid" 2>/dev/null; do
    (( SECONDS < deadline )) || return 1
    sleep 1
  done
}

clear_pid_file() {
  local expected="$1" current=""
  [[ -r "$RUN_DIR/app.pid" ]] && current="$(<"$RUN_DIR/app.pid")"
  if [[ "$current" == "$expected" ]] && ! kill -0 "$expected" 2>/dev/null; then
    rm -f "$RUN_DIR/app.pid"
  fi
}

terminate_application_pid() {
  local pid="$1"
  [[ "$pid" =~ ^[0-9]+$ && "$pid" != "$$" ]] || return 0
  if kill -0 "$pid" 2>/dev/null; then
    kill -TERM "$pid" 2>/dev/null || return 1
    wait_for_pid_exit "$pid" 60 || return 1
  fi
  clear_pid_file "$pid"
}

stop_application() {
  local pid=""
  [[ -r "$RUN_DIR/app.pid" ]] && pid="$(<"$RUN_DIR/app.pid")"
  [[ "$pid" =~ ^[0-9]+$ ]] || pid="${ERP_APP_PID:-}"
  [[ "$pid" =~ ^[0-9]+$ ]] || fail "无法读取 ERP 服务 PID" 75
  [[ "$pid" != "$$" ]] || fail "拒绝停止更新助手自身" 75
  terminate_application_pid "$pid" || fail "ERP 服务 PID $pid 未能在 60 秒内停止" 75
  printf '%s\n' "$pid"
}

backup_postgres_if_enabled() {
  [[ "$BACKUP_MODE" == "pg_dump" ]] || return 0
  require_command pg_dump
  local backup_dir="$INSTALL_ROOT/backups"
  mkdir -p "$backup_dir"
  local output="$backup_dir/pre-${TARGET_VERSION}-$(date -u +%Y%m%dT%H%M%SZ).dump"
  write_state INSTALLING 60 "正在创建 PostgreSQL 逻辑备份" "$TARGET_VERSION"
  PGPASSWORD="${OA_DB_PASSWORD:-}" PGSSLMODE="${ERP_PGSSLMODE:-prefer}" pg_dump \
    --host="${ERP_PGHOST:-127.0.0.1}" \
    --port="${ERP_PGPORT:-5432}" \
    --username="${OA_DB_USERNAME:-}" \
    --dbname="${ERP_PGDATABASE:-oa}" \
    --format=custom --file="$output" \
    || fail "PostgreSQL 备份失败，已取消更新" 60
  chmod 600 "$output"
}

install_release() {
  local requested="${1:-}"
  validate_https_url "$GITEA_BASE_URL"
  [[ "$HEALTH_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] \
    || fail "健康检查超时时间配置无效"
  [[ "$HEALTH_POLL_SECONDS" =~ ^[1-9][0-9]*$ ]] \
    || fail "健康检查间隔配置无效"
  [[ "$REPOSITORY" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$ ]] \
    || fail "更新仓库配置无效"
  require_command curl
  require_command python3
  require_command tar
  TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-update.XXXXXX")"
  create_curl_config

  write_state CHECKING 5 "正在读取 Gitea Release" "$requested"
  local owner="${REPOSITORY%%/*}" repo="${REPOSITORY#*/}"
  local api_url="${GITEA_BASE_URL%/}/api/v1/repos/$owner/$repo/releases/latest"
  local release_json="$TMP_DIR/release.json"
  download "$api_url" "$release_json"

  local selection="$TMP_DIR/selection"
  select_release "$release_json" "$requested" "$selection" || fail "Release 元数据验证失败" 10
  local release_tag release_base archive_url sums_url signature_url
  IFS=$'\t' read -r TARGET_VERSION release_tag < "$selection"
  local archive_name="kaidi-erp-${TARGET_VERSION}.tar.gz"
  release_base="${GITEA_BASE_URL%/}/$owner/$repo/releases/download/$release_tag"
  archive_url="$release_base/$archive_name"
  sums_url="$release_base/SHA256SUMS"
  signature_url="$release_base/SHA256SUMS.sig"
  local archive="$TMP_DIR/$archive_name" sums="$TMP_DIR/SHA256SUMS" signature="$TMP_DIR/SHA256SUMS.sig"

  write_state DOWNLOADING 20 "正在下载版本 ${TARGET_VERSION}" "$TARGET_VERSION"
  download "$archive_url" "$archive"
  download "$sums_url" "$sums"
  download "$signature_url" "$signature"
  verify_signature_and_checksum "$archive" "$sums" "$signature" "$archive_name"

  write_state INSTALLING 55 "正在准备版本 ${TARGET_VERSION}" "$TARGET_VERSION"
  local unpack="$TMP_DIR/unpack"
  extract_archive "$archive" "$unpack" || fail "安装包结构验证失败" 55
  local source="$unpack/kaidi-erp-${TARGET_VERSION}"
  [[ -r "$source/app/kaidi-erp.jar" && -x "$source/bin/erp-run" && -x "$source/bin/erp-update" ]] \
    || fail "安装包缺少运行文件" 55
  [[ "$(<"$source/VERSION")" == "$TARGET_VERSION" ]] || fail "安装包版本与 Release 不一致" 55
  python3 - "$source/manifest.json" "$TARGET_VERSION" <<'PY' || fail "版本清单不允许自动回滚" 55
import json, sys
manifest = json.load(open(sys.argv[1], encoding="utf-8"))
if manifest.get("version") != sys.argv[2] or manifest.get("database") != "postgresql":
    raise SystemExit(1)
if manifest.get("rollbackCompatible") is not True:
    raise SystemExit(1)
PY

  mkdir -p "$RELEASES_DIR"
  local release_dir="$RELEASES_DIR/$TARGET_VERSION"
  if [[ -r "$CURRENT_LINK/VERSION" && "$(<"$CURRENT_LINK/VERSION")" == "$TARGET_VERSION" ]]; then
    fail "目标版本已经安装" 55
  fi
  local replaced=""
  if [[ -e "$release_dir" ]]; then
    replaced="${release_dir}.replaced-$$"
    mv "$release_dir" "$replaced"
  fi
  if ! mv "$source" "$release_dir"; then
    [[ -z "$replaced" || ! -e "$replaced" ]] || mv "$replaced" "$release_dir"
    fail "无法写入目标版本目录" 55
  fi
  [[ -z "$replaced" ]] || rm -rf "$replaced"
  backup_postgres_if_enabled

  local previous=""
  [[ -L "$CURRENT_LINK" ]] && previous="$(readlink "$CURRENT_LINK")"
  [[ -n "$previous" ]] || fail "当前版本链接不存在，拒绝无回滚点更新" 70
  atomic_switch "releases/$TARGET_VERSION"

  write_state RESTARTING 80 "正在重启 ERP 服务" "$TARGET_VERSION"
  local old_pid
  old_pid="$(stop_application)"
  if wait_for_restarted_health "$old_pid"; then
    write_state SUCCEEDED 100 "更新完成" "$TARGET_VERSION"
    FINAL_STATE=1
    say "updated to $TARGET_VERSION"
    return 0
  fi

  write_state ROLLING_BACK 90 "新版本健康检查失败，正在回滚" "$TARGET_VERSION"
  atomic_switch "$previous"
  local failed_pid=""
  [[ -r "$RUN_DIR/app.pid" ]] && failed_pid="$(<"$RUN_DIR/app.pid")"
  terminate_application_pid "$failed_pid" || true
  if wait_for_restarted_health "$failed_pid"; then
    write_state ROLLED_BACK 100 "新版本不可用，已恢复上一版本" "$TARGET_VERSION" "健康检查失败"
    FINAL_STATE=1
    exit 1
  fi
  fail "新版本与回滚版本均未通过健康检查，需要人工处理" 100
}

main() {
  local command="${1:-}" version="${2:-}"
  case "$command" in
    install)
      acquire_lock_or_reexec "$@"
      install_release "$version"
      ;;
    *) printf 'usage: erp-update install <version>\n' >&2; exit 2 ;;
  esac
}

if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
  main "$@"
fi
