#!/usr/bin/env bash
# SBOM to Secure60 software inventory – runs SBOM generator, flattens CycloneDX to events, POSTs to Collector or Ingest.
# Inventory events are sent in BATCHES as NDJSON: each inventory-package-present event is one compact
# JSON object on its own line, POSTed in groups of S60_INGEST_BATCH_SIZE (default 1000), so a typical
# OS scan makes a handful of requests instead of one per package. The Secure60 collector/ingest (a
# Vector pipeline) frames the HTTP body by newline — one JSON object per line, NOT a bracketed array.
# Usage: ./s60-software-inv-agent-linux-1.05.sh [path]
#   path  Optional scan target (default: /). Config from .env-s60-client-script or prompts (Collector vs Ingest, URL, Project ID, Token).
#   --scripted  Non-interactive: require config from env or .env; use S60_SETUP_CRON=1 to add cron. No prompts. Installed cron runs with --scripted so it uses .env only.
#   S60_INSTALL_DIR  When set (e.g. /opt/secure60), the cron entry uses this path so crontab points to the canonical install dir. Script and .env-s60-client-script must be in the same directory.
#   S60_CRON_LOG  When installing cron, if this path (default /var/log/s60-sbom.log) is writable, cron output is appended there and a logrotate drop-in (/etc/logrotate.d/s60-sbom) is installed so the log is rotated (weekly, keep 4). If not writable, cron is still installed without logging. Works on Rocky and Ubuntu.
#   --sbom-file=<path>  Use this SBOM file instead of running Trivy (for E2E testing; Trivy not required).
#   --install-trivy-if-missing=Y  If Trivy is not on PATH, install it (see Trivy install sources below). Use =N to decline. In scripted mode, set to Y/1 to install non-interactively.
#   S60_INSTALL_TRIVY_IF_MISSING  Env or .env: 1/Y/yes = install Trivy if missing; 0/N/no = do not install; unset = prompt when missing.
#   --install-trivy-only  Install Trivy (trusted source) if missing, then exit (no config, no scan). Useful for Ansible/rollout to ensure Trivy is present.
#   --setup-only  (with --scripted) Write config, install/replace the cron schedule, and ensure Trivy — then exit WITHOUT running a scan. Idempotent: safe to re-run; cron performs the first and ongoing scans. Ideal for fleet rollout (e.g. Ansible).
#   Cron is idempotent: re-running (even from a different path or a newer script version) REPLACES the previous Secure60 inventory cron line instead of adding a second one, so you never get competing scans.
#   Trivy install sources (supply-chain safety): (1) a local package file S60_TRIVY_PKG_FILE (rollout tooling copies a verified .rpm/.deb to the host and points here); (2) the Secure60-hosted package downloaded from S60_TRIVY_PKG_BASE (default https://www.secure60.io/docs), file trivy_<version>_<arch>.<rpm|deb>; (3) the PUBLIC Aqua repo (pinned, then latest) ONLY if opted in. Sources 1-2 are SHA256-verified and installed via the package manager (so Trivy registers in the rpm/dpkg DB and is itself scannable). Requires sudo (or root).
#   S60_INSTALL_LATEST_TRIVY_REPO / --install-latest-trivy-repo  Allow falling back to the PUBLIC Aqua repo (pinned <version>, then latest) when the trusted package is unavailable. Off by default; scripted runs must set this, interactive runs are prompted.
#   S60_TRIVY_VERSION  Pinned Trivy version (supply-chain safety). Default matches the Secure60-hosted package; set =latest to opt into newest from the repo path (not recommended).
#   S60_TRIVY_PKG_BASE / S60_TRIVY_PKG_FILE / S60_TRIVY_RPM_SHA256 / S60_TRIVY_DEB_SHA256  Trusted-source URL base, an explicit local package path, and known-good x86_64 checksums (override to bump version / add arches).
#   S60_TRIVY_SKIP_DIRS  Comma/space-separated directories Trivy must NOT walk (pseudo-filesystems + high-churn DB/container data dirs). Generic distro defaults only (proc/sys/dev/run + docker/clickhouse/mysql/postgres/etc.); avoids the "file vanished mid-walk" FATAL and speeds up the scan.
#   S60_TRIVY_SKIP_DIRS_EXTRA  Deployment-specific data paths to skip, APPENDED to S60_TRIVY_SKIP_DIRS. Use this for your own install paths (e.g. /opt/<product>/<db>) so the default list stays generic/portable.
#   S60_INGEST_BATCH_SIZE  Number of inventory-package-present events per HTTP request (NDJSON: one JSON object per line). Default 1000. Lower it only if a proxy/collector rejects large bodies; raise it to send fewer, larger requests.
# First run (interactive): you will be prompted for Ingest or Collector, URL, Project ID, and JWT Token; values are saved to .env-s60-client-script in the script directory. To reconfigure, edit that file or remove it and run again.
# Requires: trivy (on PATH) unless --sbom-file is used. curl. No jq required.
set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
ENV_FILE_NAME=".env-s60-client-script"

# --- Parse --scripted, --sbom-file, --install-trivy-if-missing, --install-trivy-only, and scan path
S60_SCRIPTED_INSTALL="${S60_SCRIPTED_INSTALL:-}"
S60_INSTALL_TRIVY_IF_MISSING="${S60_INSTALL_TRIVY_IF_MISSING:-}"
S60_INSTALL_TRIVY_ONLY=""
S60_SETUP_ONLY="${S60_SETUP_ONLY:-}"
S60_INSTALL_LATEST_TRIVY_REPO="${S60_INSTALL_LATEST_TRIVY_REPO:-}"
SCAN_PATH="/"
S60_SBOM_FILE=""
for arg in "$@"; do
  if [ "$arg" = "--scripted" ]; then
    S60_SCRIPTED_INSTALL=1
  elif [ "${arg#--sbom-file=}" != "$arg" ]; then
    S60_SBOM_FILE="${arg#--sbom-file=}"
  elif [ "${arg#--install-trivy-if-missing=}" != "$arg" ]; then
    val="${arg#--install-trivy-if-missing=}"
    case "${val^^}" in
      Y|YES|1) S60_INSTALL_TRIVY_IF_MISSING=1 ;;
      N|NO|0)  S60_INSTALL_TRIVY_IF_MISSING=0 ;;
      *)       S60_INSTALL_TRIVY_IF_MISSING="" ;;
    esac
  elif [ "$arg" = "--install-trivy-only" ]; then
    S60_INSTALL_TRIVY_ONLY=1
  elif [ "$arg" = "--setup-only" ]; then
    S60_SETUP_ONLY=1
  elif [ "$arg" = "--install-latest-trivy-repo" ]; then
    S60_INSTALL_LATEST_TRIVY_REPO=1
  elif [ "$arg" != "--scripted" ] && [ "${arg#--sbom-file=}" = "$arg" ] && [ "${arg#--install-trivy-if-missing=}" = "$arg" ] && [ "$arg" != "--install-trivy-only" ] && [ "$arg" != "--setup-only" ] && [ "$arg" != "--install-latest-trivy-repo" ] && [ -z "${SCAN_PATH_SET:-}" ]; then
    SCAN_PATH="$arg"
    SCAN_PATH_SET=1
  fi
done
[ -n "$S60_SCRIPTED_INSTALL" ] && [ "$S60_SCRIPTED_INSTALL" != "0" ] || S60_SCRIPTED_INSTALL=""

# Resolve env file: prefer .env-s60-client-script in script dir, then cwd, then legacy .env
ENV_FILE=""
if [ -f "${SCRIPT_DIR}/${ENV_FILE_NAME}" ]; then
  ENV_FILE="${SCRIPT_DIR}/${ENV_FILE_NAME}"
elif [ -f "${PWD}/${ENV_FILE_NAME}" ]; then
  ENV_FILE="${PWD}/${ENV_FILE_NAME}"
elif [ -f "${SCRIPT_DIR}/.env" ]; then
  ENV_FILE="${SCRIPT_DIR}/.env"
elif [ -f ".env" ]; then
  ENV_FILE=".env"
fi

if [ -n "$ENV_FILE" ]; then
  set -a
  # shellcheck source=/dev/null
  source "$ENV_FILE"
  set +a
fi

# Allow token from vulnerability-manager .env (ENV_S60APITOKEN) if TOKEN not set
if [ -z "${TOKEN:-}" ] && [ -n "${ENV_S60APITOKEN:-}" ]; then
  export TOKEN="$ENV_S60APITOKEN"
fi

# Normalize S60_INSTALL_TRIVY_IF_MISSING (from CLI or env) to 1/0/""
case "${S60_INSTALL_TRIVY_IF_MISSING:-}" in
  Y|YES|1) S60_INSTALL_TRIVY_IF_MISSING=1 ;;
  N|NO|0)  S60_INSTALL_TRIVY_IF_MISSING=0 ;;
  *)       S60_INSTALL_TRIVY_IF_MISSING="" ;;
esac

# --- Trivy: OS detection and package-manager install (official Aqua repos)
S60_PKG_OS=""
_detect_pkg_os() {
  S60_PKG_OS=""
  if [ -f /etc/rocky-release ] || [ -f /etc/redhat-release ] || ( [ -f /etc/os-release ] && grep -qEi "rocky|rhel|centos" /etc/os-release 2>/dev/null ); then
    S60_PKG_OS="rhel"
  elif [ -f /etc/debian_version ] || ( [ -f /etc/os-release ] && grep -qEi "debian|ubuntu" /etc/os-release 2>/dev/null ); then
    S60_PKG_OS="debian"
  fi
}

_trivy_installed() {
  command -v trivy &>/dev/null
}

# Run command with sudo if not root (for package-manager install).
_run_priv() {
  if [ "$(id -u)" -eq 0 ]; then
    "$@"
  else
    sudo "$@"
  fi
}

# --- Trivy version pin (supply-chain safety) ---------------------------------
# Installs are PINNED to a known-good Trivy release rather than whatever the repo
# currently calls "latest", so a compromised or yanked upstream release cannot be
# pulled onto customer hosts automatically. Bump this ONLY after reviewing the
# target release; the GPG-signed repo still verifies the package, the pin decides
# *which* signed version we accept. Set S60_TRIVY_VERSION=latest to opt back into
# the newest available (not recommended). Overridable via env or .env-s60-client-script.
S60_TRIVY_VERSION="${S60_TRIVY_VERSION:-0.71.2}"

# --- Trivy install SOURCES (supply-chain safety) -----------------------------
# Trivy's upstream releases have been tampered with in the past, so we DO NOT pull from the public
# Aqua repo by default. Install order is:
#   1. A LOCAL package file (S60_TRIVY_PKG_FILE) — rollout tooling (Ansible) copies the verified
#      .rpm/.deb to the host and points this at it, so locked-down hosts need no egress.
#   2. The Secure60-HOSTED package downloaded from S60_TRIVY_PKG_BASE (filename
#      trivy_<version>_<arch>.<rpm|deb>, Aqua's asset naming).
#   3. The PUBLIC Aqua repo (pinned <version>, then latest) — ONLY when explicitly opted in via
#      S60_INSTALL_LATEST_TRIVY_REPO=1 / --install-latest-trivy-repo (scripted) or the interactive
#      prompt. Otherwise the install fails with guidance.
# Steps 1 & 2 install via the package manager (so Trivy registers in the rpm/dpkg DB and is itself
# covered by vulnerability scanning) and are SHA256-verified against the known-good values below.
S60_TRIVY_PKG_BASE="${S60_TRIVY_PKG_BASE:-https://www.secure60.io/docs}"
S60_TRIVY_PKG_FILE="${S60_TRIVY_PKG_FILE:-}"
# Known-good SHA256 of the trusted x86_64 packages for the pinned version above (override when you
# bump the version or host other architectures; empty disables verification — NOT recommended).
S60_TRIVY_RPM_SHA256="${S60_TRIVY_RPM_SHA256:-e655c397a8584f0cd33cb487c025af0ecd603d0380f0d7c0defba866a7b72559}"
S60_TRIVY_DEB_SHA256="${S60_TRIVY_DEB_SHA256:-e0fefd02010dfa96938cfa2b817db09f710ccec044a305dfdd567e52c5702a59}"

# Directories Trivy must NOT walk during the filesystem scan (comma- or space-separated). These are
# pseudo-filesystems and high-churn DATA directories (databases, container layers) that contain no
# installable packages — and whose files can be deleted mid-walk (e.g. a database merging data parts),
# which Trivy treats as a FATAL error that aborts the whole scan. Skipping them also makes the scan far
# faster. rpm/dpkg DBs are NOT skipped. This default list is intentionally GENERIC (standard distro
# paths only); add DEPLOYMENT-SPECIFIC data paths via S60_TRIVY_SKIP_DIRS_EXTRA so this stays portable.
S60_TRIVY_SKIP_DIRS="${S60_TRIVY_SKIP_DIRS:-/proc,/sys,/dev,/run,/var/lib/docker,/var/lib/containerd,/var/lib/clickhouse,/var/lib/mysql,/var/lib/postgresql,/var/lib/elasticsearch,/var/lib/mongodb}"
# Extra deployment-specific data directories to skip, APPENDED to the generic defaults above. Set this
# (env / .env / rollout tooling) for your own data paths so the published default list stays generic.
S60_TRIVY_SKIP_DIRS_EXTRA="${S60_TRIVY_SKIP_DIRS_EXTRA:-}"

_install_trivy_rhel() {
  local repo_file="/etc/yum.repos.d/trivy.repo"
  if [ -f "$repo_file" ]; then
    echo "Trivy repo already present: $repo_file"
  else
    echo "Adding Trivy official repository..."
    cat << 'TRIVY_REPO_EOF' | _run_priv tee "$repo_file" >/dev/null
[trivy]
name=Trivy repository
baseurl=https://aquasecurity.github.io/trivy-repo/rpm/releases/$basearch/
gpgcheck=1
enabled=1
gpgkey=https://aquasecurity.github.io/trivy-repo/rpm/public.key
TRIVY_REPO_EOF
    [ -f "$repo_file" ] || { echo "Error: failed to add Trivy repo." >&2; return 1; }
  fi
  local mgr="yum"; command -v dnf &>/dev/null && mgr="dnf"
  [ "$mgr" = "yum" ] && _run_priv yum -y makecache
  if [ "$S60_TRIVY_VERSION" != "latest" ]; then
    echo "Installing trivy-${S60_TRIVY_VERSION} (pinned; set S60_TRIVY_VERSION=latest to override)..."
    if _run_priv "$mgr" -y install "trivy-${S60_TRIVY_VERSION}"; then
      return 0
    fi
    # Aqua's repo only keeps a rolling window of recent releases, so an older pin ages out and
    # "dnf install trivy-<pin>" returns "No match". Fall back to the latest signed package rather
    # than failing the whole install — the repo is GPG-signed, so integrity is still enforced.
    echo "Warning: pinned Trivy ${S60_TRIVY_VERSION} is not available in the repo (it keeps only recent releases). Falling back to the latest signed package." >&2
  fi
  echo "Installing latest trivy (GPG-verified from the official repo)..."
  _run_priv "$mgr" -y install trivy
}

_install_trivy_debian() {
  local keyring="/usr/share/keyrings/trivy.gpg"
  local list_file="/etc/apt/sources.list.d/trivy.list"
  echo "Adding Trivy official repository..."
  _run_priv apt-get install -y wget gnupg
  wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | _run_priv gpg --dearmor -o "$keyring"
  echo "deb [signed-by=$keyring] https://aquasecurity.github.io/trivy-repo/deb generic main" | _run_priv tee "$list_file" >/dev/null
  _run_priv apt-get update
  if [ "$S60_TRIVY_VERSION" != "latest" ]; then
    echo "Installing trivy=${S60_TRIVY_VERSION} (pinned; set S60_TRIVY_VERSION=latest to override)..."
    if _run_priv apt-get install -y "trivy=${S60_TRIVY_VERSION}"; then
      return 0
    fi
    # Repo keeps only recent releases, so an older pin may be gone. Fall back to latest (GPG-signed).
    echo "Warning: pinned Trivy ${S60_TRIVY_VERSION} is not available in the repo (it keeps only recent releases). Falling back to the latest signed package." >&2
  fi
  echo "Installing latest trivy (GPG-verified from the official repo)..."
  _run_priv apt-get install -y trivy
}

# Compute the SHA256 of a file using whatever tool is present. Empty if none.
_sha256_of() {
  if command -v sha256sum &>/dev/null; then sha256sum "$1" 2>/dev/null | awk '{print $1}'
  elif command -v shasum &>/dev/null; then shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'
  else printf ''; fi
}

# Map the CPU arch to Aqua's package asset suffix. Empty if unsupported.
_trivy_arch_suffix() {
  case "$(uname -m 2>/dev/null)" in
    x86_64|amd64)  printf 'Linux-64bit' ;;
    aarch64|arm64) printf 'Linux-ARM64' ;;
    *)             printf '' ;;
  esac
}

# rpm for RHEL-likes, deb for Debian-likes. Empty if unsupported.
_trivy_pkg_ext() {
  case "$S60_PKG_OS" in
    rhel)   printf 'rpm' ;;
    debian) printf 'deb' ;;
    *)      printf '' ;;
  esac
}

# Known-good SHA256 for the resolved package type, if we have one for this arch (x86_64 only by default).
_trivy_expected_sha() {
  [ "$(_trivy_arch_suffix)" = "Linux-64bit" ] || return 0
  case "$1" in
    rpm) printf '%s' "$S60_TRIVY_RPM_SHA256" ;;
    deb) printf '%s' "$S60_TRIVY_DEB_SHA256" ;;
  esac
}

# Verify a LOCAL package file ($1) against the known-good SHA256 for its type ($2=rpm|deb), then
# install it via the package manager (registers in the rpm/dpkg DB). Refuses on mismatch or when no
# checksum tool is available. Returns 0 on success.
_install_trivy_localfile() {
  local file="$1" ext="$2" expected actual
  [ -f "$file" ] || { echo "Trivy package file not found: $file" >&2; return 1; }
  expected="$(_trivy_expected_sha "$ext")"
  if [ -n "$expected" ]; then
    actual="$(_sha256_of "$file")"
    if [ -z "$actual" ]; then
      echo "Error: no sha256 tool (sha256sum/shasum) available to verify Trivy; refusing to install unverified package." >&2
      return 1
    fi
    if [ "$actual" != "$expected" ]; then
      echo "Error: Trivy package SHA256 mismatch for ${file}" >&2
      echo "  expected ${expected}" >&2
      echo "  actual   ${actual}" >&2
      echo "  Refusing to install — the package does not match the known-good checksum." >&2
      return 1
    fi
    echo "Trivy package SHA256 verified (${ext})."
  else
    echo "Warning: no known-good SHA256 for this version/arch ($(uname -m), ${ext}); installing without verification." >&2
  fi
  echo "Installing Trivy from local package ${file} (registers in the OS package DB)..."
  if [ "$ext" = "rpm" ]; then
    if command -v dnf &>/dev/null; then _run_priv dnf -y install "$file"; else _run_priv yum -y install "$file"; fi
  else
    _run_priv apt-get install -y "$file"
  fi
}

# Trusted install: a provided local file first (rollout tooling), else download the Secure60-hosted
# package from S60_TRIVY_PKG_BASE. Never touches the public Aqua repo.
_install_trivy_trusted() {
  local ext; ext="$(_trivy_pkg_ext)"
  [ -n "$ext" ] || { echo "Trusted Trivy install: unsupported OS (S60_PKG_OS=$S60_PKG_OS)." >&2; return 1; }
  if [ -n "$S60_TRIVY_PKG_FILE" ]; then
    echo "Installing Trivy from provided package: $S60_TRIVY_PKG_FILE"
    _install_trivy_localfile "$S60_TRIVY_PKG_FILE" "$ext"
    return $?
  fi
  local arch; arch="$(_trivy_arch_suffix)"
  [ -n "$arch" ] || { echo "Trusted Trivy install: unsupported CPU arch $(uname -m 2>/dev/null)." >&2; return 1; }
  local fname="trivy_${S60_TRIVY_VERSION}_${arch}.${ext}"
  local url="${S60_TRIVY_PKG_BASE%/}/${fname}"
  local tmp; tmp="$(mktemp -t trivy-pkg.XXXXXX)"
  echo "Downloading trusted Trivy package: ${url}"
  if ! curl -fsSL -o "$tmp" "$url"; then
    echo "Trusted Trivy package not available at ${url}" >&2
    rm -f "$tmp"; return 1
  fi
  _install_trivy_localfile "$tmp" "$ext"; local rc=$?
  rm -f "$tmp"
  return $rc
}

# Decide whether the PUBLIC Aqua repo fallback is permitted: scripted needs the explicit opt-in,
# interactive offers a prompt.
_trivy_repo_opt_in() {
  case "${S60_INSTALL_LATEST_TRIVY_REPO:-}" in
    1|Y|y|YES|yes|true) return 0 ;;
  esac
  if [ -z "$S60_SCRIPTED_INSTALL" ]; then
    read -rp "Trusted Secure60-hosted Trivy was unavailable. Install from the PUBLIC Aqua repo instead (pinned ${S60_TRIVY_VERSION}, then latest)? (y/N): " _ans
    case "${_ans:-N}" in y|Y|yes|YES) return 0 ;; esac
  fi
  return 1
}

# Orchestrate: trusted source first; public Aqua repo only with explicit opt-in.
_install_trivy() {
  if _install_trivy_trusted; then
    return 0
  fi
  if _trivy_repo_opt_in; then
    echo "Falling back to the public Aqua repo (pinned ${S60_TRIVY_VERSION}, then latest)..."
    if [ "$S60_PKG_OS" = "rhel" ]; then _install_trivy_rhel; return $?; fi
    if [ "$S60_PKG_OS" = "debian" ]; then _install_trivy_debian; return $?; fi
    echo "Error: package-manager install not supported on this OS (S60_PKG_OS=$S60_PKG_OS)." >&2
  fi
  return 1
}

# --install-trivy-only: install Trivy via package manager if missing, then exit (no config, no scan).
if [ -n "$S60_INSTALL_TRIVY_ONLY" ]; then
  _detect_pkg_os
  if _trivy_installed; then
    echo "Trivy is already installed: $(command -v trivy)"
    exit 0
  fi
  if [ -z "$S60_PKG_OS" ]; then
    echo "Error: Trivy not found and package-manager install not supported on this OS. Install Trivy manually or see https://trivy.dev/docs/latest/getting-started/installation/" >&2
    exit 1
  fi
  if _install_trivy; then
    hash -r 2>/dev/null || true
    echo "Trivy installed successfully: $(command -v trivy)"
    exit 0
  else
    echo "Error: Trivy installation failed (trusted package unavailable; public Aqua repo not used unless opted in)." >&2
    echo "  Host trivy_${S60_TRIVY_VERSION}_<arch>.<rpm|deb> at ${S60_TRIVY_PKG_BASE} (or pass S60_TRIVY_PKG_FILE), or add --install-latest-trivy-repo to allow the public repo." >&2
    exit 1
  fi
fi

export HOST="${HOST:-$(hostname 2>/dev/null || echo unknown)}"
export IP="${IP:-}"
export FQDN="${FQDN:-}"
export ENVIRONMENT="${environment:-Production}"
# Running kernel release (e.g. 5.14.0-687.15.1.el9_8.x86_64 / 5.15.0-1051-aws). Sent on every inventory
# event so the server can tag kernel packages as running vs non-running. Empty if uname is unavailable —
# the server treats "unknown" as fail-open (never suppresses a threat), so an empty value stays safe.
export RUNNING_KERNEL_VERSION="${RUNNING_KERNEL_VERSION:-$(uname -r 2>/dev/null || echo '')}"
readonly INV_PKG_SOURCE="sbom"
readonly APP_NAME_VULN="s60-vulnerability-scanning"

# --- Collector vs Ingest (and related config)
# Detect if we need to prompt (interactive and missing required config)
NEED_CONFIG=""
if [ -z "$S60_SCRIPTED_INSTALL" ]; then
  if [ -z "${S60_SEND_MODE:-}" ] || [ "$S60_SEND_MODE" != "collector" ] && [ "$S60_SEND_MODE" != "ingest" ]; then
    NEED_CONFIG=1
  elif [ "$S60_SEND_MODE" = "ingest" ] && { [ -z "${PROJECT_ID:-}" ] || [ -z "${TOKEN:-}" ]; }; then
    NEED_CONFIG=1
  elif [ "$S60_SEND_MODE" = "collector" ] && [ -z "${S60_COLLECTOR_BASE:-}" ]; then
    NEED_CONFIG=1
  fi
fi

if [ -n "$NEED_CONFIG" ]; then
  echo ""
  echo "--- Secure60 configuration (first run or missing settings) ---"
  echo "Where to send scan results: Collector (on‑prem) or Ingest (cloud)."
  echo "You will be asked for: mode, URL, and for Ingest: Project ID and JWT Token."
  echo "Values are saved in .env-s60-client-script in this directory for next run."
  echo ""
fi

S60_SEND_MODE="${S60_SEND_MODE:-}"
if [ -z "$S60_SEND_MODE" ] || [ "$S60_SEND_MODE" != "collector" ] && [ "$S60_SEND_MODE" != "ingest" ]; then
  if [ -n "$S60_SCRIPTED_INSTALL" ]; then
    echo "Error: S60_SCRIPTED_INSTALL=1 but S60_SEND_MODE not set. Set S60_SEND_MODE=collector or ingest." >&2
    exit 1
  fi
  read -rp "Send to Secure60 Collector or Ingest directly? (C)ollector / (I)ngest [I]: " choice
  choice="${choice:-I}"
  if [ "${choice^^}" = "C" ]; then
    S60_SEND_MODE="collector"
  else
    S60_SEND_MODE="ingest"
  fi
fi

AUTH_HEADER=""
if [ "$S60_SEND_MODE" = "collector" ]; then
  S60_COLLECTOR_BASE="${S60_COLLECTOR_BASE:-}"
  if [ -z "$S60_COLLECTOR_BASE" ]; then
    if [ -n "$S60_SCRIPTED_INSTALL" ]; then
      echo "Error: S60_SCRIPTED_INSTALL=1 but S60_COLLECTOR_BASE not set." >&2
      exit 1
    fi
    read -rp "Collector base URL (e.g. https://COLLECTOR_IP): " S60_COLLECTOR_BASE
    [ -n "$S60_COLLECTOR_BASE" ] || { echo "Collector URL is required."; exit 1; }
  fi
  S60_COLLECTOR_BASE="${S60_COLLECTOR_BASE%/}"
  [[ "$S60_COLLECTOR_BASE" =~ ^https?:// ]] || S60_COLLECTOR_BASE="https://${S60_COLLECTOR_BASE}"
  INGEST_URL="$S60_COLLECTOR_BASE/"
  PROJECT_ID=""
  TOKEN=""
else
  S60_INGEST_BASE="${S60_INGEST_BASE:-${S60_BASE:-}}"
  if [ -z "$S60_INGEST_BASE" ]; then
    if [ -n "$S60_SCRIPTED_INSTALL" ]; then
      S60_INGEST_BASE="https://ingest.secure60.io"
    else
      read -rp "Ingest base URL [https://ingest.secure60.io]: " input_base
      S60_INGEST_BASE="${input_base:-https://ingest.secure60.io}"
    fi
  fi
  S60_INGEST_BASE="${S60_INGEST_BASE%/}"
  export PROJECT_ID="${PROJECT_ID:-}"
  export TOKEN="${TOKEN:-}"
  if [ -z "$PROJECT_ID" ]; then
    if [ -n "$S60_SCRIPTED_INSTALL" ]; then
      echo "Error: S60_SCRIPTED_INSTALL=1 but PROJECT_ID not set. Set PROJECT_ID in .env-s60-client-script or env." >&2
      exit 1
    fi
    read -rp "Secure60 Project ID (number, e.g. 313): " PROJECT_ID
    [ -n "$PROJECT_ID" ] || { echo "PROJECT_ID is required."; exit 1; }
  fi
  if [ -z "$TOKEN" ]; then
    if [ -n "$S60_SCRIPTED_INSTALL" ]; then
      echo "Error: S60_SCRIPTED_INSTALL=1 but TOKEN not set. Set TOKEN (or ENV_S60APITOKEN) in .env-s60-client-script or env." >&2
      exit 1
    fi
    read -rp "Secure60 JWT Token (from Secure60 UI or API): " TOKEN
    [ -n "$TOKEN" ] || { echo "TOKEN is required."; exit 1; }
  fi
  INGEST_URL="${S60_INGEST_BASE%/}/ingest/1.0/http/project/${PROJECT_ID}"
  AUTH_HEADER="Authorization: Bearer $TOKEN"
fi

# Save resolved config so next run (and cron) can load it without env vars (create or update .env-s60-client-script in script dir)
ENV_FILE="${SCRIPT_DIR}/${ENV_FILE_NAME}"
{
  echo "# Secure60 client script config. Created/updated by ${SCRIPT_NAME}. chmod 600 recommended (contains TOKEN for ingest)."
  echo "# Edit this file to change Ingest/Collector URL, Project ID, or Token."
  echo "# S60_SEND_MODE=collector or ingest"
  echo "S60_SEND_MODE=$S60_SEND_MODE"
  if [ "$S60_SEND_MODE" = "collector" ]; then
    echo "S60_COLLECTOR_BASE=$S60_COLLECTOR_BASE"
  else
    echo "S60_INGEST_BASE=$S60_INGEST_BASE"
    echo "PROJECT_ID=$PROJECT_ID"
    echo "TOKEN=$TOKEN"
  fi
  # Persist the Trivy auto-install preference + pinned version so that CRON and manual --scripted runs
  # (which only read this .env) can self-install Trivy when it is missing — without the env var having
  # to be re-passed each time. Without this, a host whose Trivy install failed would never self-heal.
  if [ "$S60_INSTALL_TRIVY_IF_MISSING" = "1" ] || [ "$S60_INSTALL_TRIVY_IF_MISSING" = "0" ]; then
    echo "S60_INSTALL_TRIVY_IF_MISSING=$S60_INSTALL_TRIVY_IF_MISSING"
  fi
  echo "S60_TRIVY_VERSION=$S60_TRIVY_VERSION"
  # Persist deployment-specific Trivy skip-dirs so cron uses the same exclusions (the generic base list
  # comes from the script itself). Avoids the data-dir mid-walk FATAL on scheduled scans.
  [ -n "$S60_TRIVY_SKIP_DIRS_EXTRA" ] && echo "S60_TRIVY_SKIP_DIRS_EXTRA=$S60_TRIVY_SKIP_DIRS_EXTRA"
  echo "# Optional: HOST= IP= FQDN= environment="
} > "$ENV_FILE"
if [ -z "$S60_SCRIPTED_INSTALL" ]; then
  echo "Config saved to ${ENV_FILE} (use for next run; edit to change Ingest URL, Project ID, or Token)."
fi

# --- Cron: set up BEFORE the scan so the schedule + config are in place even if the
# first scan runs long or is interrupted (the first Trivy run can take several minutes).
# Optional logging: if S60_CRON_LOG is writable, cron line appends output there (safe on Rocky, Ubuntu).
_cron_install() {
  if [ -n "${S60_INSTALL_DIR:-}" ]; then
    SCRIPT_ABS="${S60_INSTALL_DIR}/${SCRIPT_NAME}"
  else
    SCRIPT_ABS="${SCRIPT_DIR}/${SCRIPT_NAME}"
  fi
  # Self-contained PATH on the command so Trivy is found regardless of crontab header (it may live in
  # /usr/local/bin from a manual install). The trailing marker comment lets re-runs find & replace this
  # line no matter where the script was installed or which version created it.
  local marker="# secure60-sw-inv-agent"
  CRON_LINE="0 */6 * * * PATH=/usr/local/bin:/usr/bin:/bin ${SCRIPT_ABS} --scripted"
  S60_CRON_LOG="${S60_CRON_LOG:-/var/log/s60-sbom.log}"
  if echo >> "${S60_CRON_LOG}" 2>/dev/null; then
    CRON_LINE="${CRON_LINE} >> ${S60_CRON_LOG} 2>&1"
    echo "Cron output will be logged to ${S60_CRON_LOG}"
    # Install logrotate drop-in so the log is rotated (weekly, keep 4) and does not fill disk.
    if ! {
      printf '%s\n' "${S60_CRON_LOG} {" "    weekly" "    rotate 4" "    compress" "    missingok" "    notifempty" "}"
    } | _run_priv tee /etc/logrotate.d/s60-sbom >/dev/null 2>&1; then
      echo "Warning: Could not install logrotate config for ${S60_CRON_LOG}. Consider adding rotation for this path to avoid disk fill (e.g. in /etc/logrotate.d/)." >&2
    fi
  fi
  CRON_LINE="${CRON_LINE} ${marker}"
  # Remove ANY previous Secure60 inventory cron line — our marker, or any line referencing an
  # s60-software-inv-agent script at any path/version — before adding the current one. This is what
  # stops a re-download-and-run (to a new path or new version) from leaving a second, competing schedule.
  local existing
  existing="$(crontab -l 2>/dev/null | grep -v -F "$marker" | grep -vE 's60-software-inv-agent[^[:space:]]*\.sh' || true)"
  local removed_prior=""
  crontab -l 2>/dev/null | grep -qE "$marker|s60-software-inv-agent[^[:space:]]*\.sh" && removed_prior=1
  if [ -z "$existing" ]; then
    printf '%s\n' "SHELL=/bin/bash" "$CRON_LINE" | crontab -
  else
    printf '%s\n' "$existing" "$CRON_LINE" | crontab -
  fi
  if [ -n "$removed_prior" ]; then
    echo "Cron installed (replaced previous Secure60 inventory schedule): $CRON_LINE"
  else
    echo "Cron installed: $CRON_LINE"
  fi
}
if [ -n "$S60_SCRIPTED_INSTALL" ]; then
  if [ -n "${S60_SETUP_CRON:-}" ] && [ "$S60_SETUP_CRON" != "0" ]; then
    _cron_install
  fi
else
  read -rp "Setup a cron job to run this script every 6 hours? (Y/n): " cron_choice
  cron_choice="${cron_choice:-Y}"
  if [ "${cron_choice^^}" = "Y" ] || [ "${cron_choice^^}" = "YES" ]; then
    _cron_install
  fi
fi

# --setup-only / S60_SETUP_ONLY: config + cron are now in place; ensure Trivy is present but DO NOT
# run a scan. Used by rollout tooling (e.g. Ansible) to converge config/cron/Trivy cheaply and
# idempotently across a fleet — cron performs the first (and ongoing) scans. Safe to re-run anytime.
if [ -n "${S60_SETUP_ONLY:-}" ] && [ "$S60_SETUP_ONLY" != "0" ]; then
  if [ -z "$S60_SBOM_FILE" ] && ! _trivy_installed; then
    _detect_pkg_os
    if [ -n "$S60_PKG_OS" ] && [ "$S60_INSTALL_TRIVY_IF_MISSING" = "1" ]; then
      echo "Trivy not found; installing now (S60_INSTALL_TRIVY_IF_MISSING=1, version ${S60_TRIVY_VERSION})..."
      _install_trivy || true
      hash -r 2>/dev/null || true
    fi
    if _trivy_installed; then
      echo "Trivy installed: $(command -v trivy)"
    fi
    if ! _trivy_installed; then
      # Auto-install was requested but Trivy still is not on PATH — FAIL LOUDLY (non-zero exit) so the
      # rollout tooling (Ansible) flags this host instead of reporting a silent "ok" with no scanner.
      if [ "$S60_INSTALL_TRIVY_IF_MISSING" = "1" ]; then
        echo "Error: Trivy install was requested (S60_INSTALL_TRIVY_IF_MISSING=1) but trivy is still not on PATH." >&2
        echo "  Tried the trusted package: ${S60_TRIVY_PKG_FILE:-${S60_TRIVY_PKG_BASE%/}/trivy_${S60_TRIVY_VERSION}_<arch>.<rpm|deb>}" >&2
        echo "  Host the matching package (rollout tooling can copy it and set S60_TRIVY_PKG_FILE), or to allow the" >&2
        echo "  PUBLIC Aqua repo instead (pinned ${S60_TRIVY_VERSION}, then latest) re-run with S60_INSTALL_LATEST_TRIVY_REPO=1." >&2
        echo "  Diagnose:  ./${SCRIPT_NAME} --install-trivy-only   (or add --install-latest-trivy-repo)." >&2
        exit 1
      fi
      echo "Warning: Trivy is not installed and auto-install was not enabled (set S60_INSTALL_TRIVY_IF_MISSING=1). Scheduled scans will not run until Trivy is present." >&2
    fi
  fi
  echo "Setup complete (config + cron + Trivy). Skipping scan (--setup-only); cron will run the first scan."
  exit 0
fi

SCAN_ID="$(uuidgen 2>/dev/null | tr '[:upper:]' '[:lower:]' || echo "s60-scan-$(date +%s)")"
TMP_SBOM=""
S60_CREATED_TMP_SBOM=""

# --- Use SBOM file or run Trivy
if [ -n "$S60_SBOM_FILE" ]; then
  if [ ! -f "$S60_SBOM_FILE" ]; then
    echo "Error: --sbom-file path does not exist: $S60_SBOM_FILE" >&2
    exit 1
  fi
  TMP_SBOM="$S60_SBOM_FILE"
  echo "Using SBOM file: $TMP_SBOM"
else
  if ! _trivy_installed; then
    _detect_pkg_os
    if [ -z "$S60_PKG_OS" ]; then
      echo "Error: trivy is not installed or not on PATH. Package-manager install not supported on this OS." >&2
      echo "Install Trivy manually or use --sbom-file=<path> for testing. See https://trivy.dev/docs/latest/getting-started/installation/" >&2
      exit 1
    fi
    do_install=""
    if [ -n "$S60_SCRIPTED_INSTALL" ]; then
      if [ "$S60_INSTALL_TRIVY_IF_MISSING" = "1" ]; then
        do_install=1
      else
        echo "Error: trivy is not installed. Set S60_INSTALL_TRIVY_IF_MISSING=1 or use --install-trivy-if-missing=Y to install." >&2
        exit 1
      fi
    else
      # Install attempt tries the trusted Secure60 package first; the public-repo fallback is prompted
      # separately (only if the trusted package is unavailable), so this just gates the attempt.
      read -rp "Trivy not found. Install it now (trusted Secure60 package)? (Y/n): " trivy_choice
      trivy_choice="${trivy_choice:-Y}"
      if [ "${trivy_choice^^}" = "Y" ] || [ "${trivy_choice^^}" = "YES" ]; then
        do_install=1
      fi
    fi
    if [ -n "$do_install" ]; then
      if ! _install_trivy; then
        echo "Error: Trivy install failed (trusted package unavailable; public Aqua repo not used)." >&2
        echo "  Host trivy_${S60_TRIVY_VERSION}_<arch>.<rpm|deb> at ${S60_TRIVY_PKG_BASE}, or re-run with S60_INSTALL_LATEST_TRIVY_REPO=1 / --install-latest-trivy-repo to allow the public repo. Or use --sbom-file=<path>." >&2
        exit 1
      fi
      hash -r 2>/dev/null || true
    else
      echo "Error: trivy is not installed. Install manually or use --sbom-file=<path>. See https://trivy.dev/docs/latest/getting-started/installation/" >&2
      exit 1
    fi
  fi
  TMP_SBOM="$(mktemp -t sbom.XXXXXX.json)"
  S60_CREATED_TMP_SBOM=1
  echo "Generating SBOM for path: $SCAN_PATH"
  echo "Note: the first scan can take several minutes — Trivy walks the filesystem (and downloads its"
  echo "database on first run). This is expected; progress is shown below, so please do not interrupt it."
  # Build --skip-dirs args from the generic defaults PLUS any deployment-specific extras.
  S60_SKIP_ARGS=()
  _old_ifs="$IFS"; IFS=', '
  for _d in $S60_TRIVY_SKIP_DIRS $S60_TRIVY_SKIP_DIRS_EXTRA; do
    [ -n "$_d" ] && S60_SKIP_ARGS+=(--skip-dirs "$_d")
  done
  IFS="$_old_ifs"
  [ "${#S60_SKIP_ARGS[@]}" -gt 0 ] && echo "Skipping data/pseudo dirs: $S60_TRIVY_SKIP_DIRS${S60_TRIVY_SKIP_DIRS_EXTRA:+,$S60_TRIVY_SKIP_DIRS_EXTRA}"
  # Run Trivy in the background and emit a heartbeat so the scan never looks hung. Trivy suppresses its
  # own progress bar when output is not a TTY (e.g. under cron), so this is the only visible feedback there.
  trivy fs --format cyclonedx --output "$TMP_SBOM" "${S60_SKIP_ARGS[@]}" "$SCAN_PATH" &
  S60_TRIVY_PID=$!
  s60_elapsed=0
  while kill -0 "$S60_TRIVY_PID" 2>/dev/null; do
    sleep 15
    kill -0 "$S60_TRIVY_PID" 2>/dev/null || break
    s60_elapsed=$((s60_elapsed + 15))
    printf '  ... still scanning — %dm%02ds elapsed\n' "$((s60_elapsed / 60))" "$((s60_elapsed % 60))"
  done
  s60_trivy_rc=0
  wait "$S60_TRIVY_PID" || s60_trivy_rc=$?
  if [ "$s60_trivy_rc" -ne 0 ]; then
    echo "Error: SBOM generation failed (trivy exit ${s60_trivy_rc})." >&2
    exit 1
  fi
  echo "SBOM generation complete."
fi

# --- Pure-bash JSON helpers (no jq). Assumes Trivy CycloneDX; no escaped " in values.
json_escape() {
  local s="$1"
  s="${s//\\/\\\\}"
  s="${s//\"/\\\"}"
  # Escape control chars so a value can never break JSON or split an NDJSON line across newlines.
  s="${s//$'\n'/\\n}"
  s="${s//$'\r'/\\r}"
  s="${s//$'\t'/\\t}"
  printf '%s' "$s"
}

# Get first string value for key "key" or "key": from blob. Key can be bom-ref, type, name, version, etc.
get_json_string() {
  local blob="$1"
  local key="$2"
  local pattern
  case "$key" in
    bom-ref) pattern='"bom-ref"[[:space:]]*:[[:space:]]*"([^"]*)"' ;;
    *)       pattern="\"${key}\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"" ;;
  esac
  if [[ "$blob" =~ $pattern ]]; then
    printf '%s' "${BASH_REMATCH[1]}"
  fi
}

# Get property value by name (e.g. aquasecurity:trivy:Class) from blob. Looks for "name":"...","value":"..."
get_json_property_value() {
  local blob="$1"
  local propname="$2"
  local escaped="${propname//:/\\:}"
  # Match "name":"aquasecurity:trivy:Class" or similar then "value":"..."
  if [[ "$blob" =~ \"name\"[[:space:]]*:[[:space:]]*\"${propname}\"[^}]*\"value\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
    printf '%s' "${BASH_REMATCH[1]}"
  fi
}

# Extract supplier.name from blob (nested "supplier": { "name": "..." })
get_json_supplier_name() {
  local blob="$1"
  if [[ "$blob" =~ \"supplier\"[[:space:]]*:[[:space:]]*\{[^}]*\"name\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
    printf '%s' "${BASH_REMATCH[1]}"
  fi
}

# Extract arch from purl qualifier (e.g. ?arch=x86_64&distro=... -> x86_64)
get_purl_arch() {
  local purl="$1"
  if [[ "$purl" =~ \?([^\x22]*)$ ]] || [[ "$purl" =~ \?(.*) ]]; then
    local q="${BASH_REMATCH[1]}"
    if [[ "$q" =~ (^|&)arch=([^&]+) ]]; then
      printf '%s' "${BASH_REMATCH[2]}"
    fi
  fi
}

# Extract hashes[i].alg and hashes[i].content for i in 0..4. want = alg | content
get_json_hashN() {
  local blob="$1"
  local idx="$2"
  local want="$3"
  local rest="$blob"
  [[ "$rest" =~ \"hashes\"[[:space:]]*:[[:space:]]*\[(.*) ]] || return
  rest="${BASH_REMATCH[1]}"
  local count=0
  while [[ "$rest" =~ \{[^}]*\"alg\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"[^}]*\"content\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"[^}]*\} ]]; do
    if [ "$count" -eq "$idx" ]; then
      if [ "$want" = "alg" ]; then printf '%s' "${BASH_REMATCH[1]}"; else printf '%s' "${BASH_REMATCH[2]}"; fi
      return
    fi
    rest="${rest:${#BASH_REMATCH[0]}}"
    count=$((count + 1))
  done
}

# Backward compat: hashes[0]
get_json_hash0() {
  get_json_hashN "$1" 0 "$2"
}

# Extract licenses[i].license.id, .name, or licenses[i].expression for i in 0..4
# Restrict search to the licenses array (between [ and matching ]) to avoid matching component "name"
get_json_licenseN() {
  local blob="$1"
  local idx="$2"
  local want="$3"
  local rest="$blob"
  [[ "$rest" =~ \"licenses\"[[:space:]]*:[[:space:]]*\[(.*) ]] || return
  rest="${BASH_REMATCH[1]}"
  local depth=1
  local pos=0
  local len=${#rest}
  while [ "$pos" -lt "$len" ] && [ "$depth" -gt 0 ]; do
    local c="${rest:$pos:1}"
    if [ "$c" = "[" ] || [ "$c" = "{" ]; then depth=$((depth + 1)); fi
    if [ "$c" = "]" ] || [ "$c" = "}" ]; then depth=$((depth - 1)); fi
    pos=$((pos + 1))
  done
  rest="${rest:0:$((pos - 1))}"
  local count=0
  if [ "$want" = "id" ]; then
    while [[ "$rest" =~ \"id\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; do
      if [ "$count" -eq "$idx" ]; then printf '%s' "${BASH_REMATCH[1]}"; return; fi
      rest="${rest:${#BASH_REMATCH[0]}}"
      count=$((count + 1))
    done
  elif [ "$want" = "name" ]; then
    while [[ "$rest" =~ \"name\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; do
      if [ "$count" -eq "$idx" ]; then printf '%s' "${BASH_REMATCH[1]}"; return; fi
      rest="${rest:${#BASH_REMATCH[0]}}"
      count=$((count + 1))
    done
  elif [ "$want" = "expression" ]; then
    while [[ "$rest" =~ \"expression\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; do
      if [ "$count" -eq "$idx" ]; then printf '%s' "${BASH_REMATCH[1]}"; return; fi
      rest="${rest:${#BASH_REMATCH[0]}}"
      count=$((count + 1))
    done
  fi
}

# Backward compat: licenses[0]
get_json_license0() {
  get_json_licenseN "$1" 0 "$2"
}

# Split SBOM file into component blobs. Finds root "components": [ (line with two leading spaces in pretty-printed CycloneDX), then outputs each { } object.
split_sbom_components() {
  local file="$1"
  local start_line
  start_line=$(grep -n '^  "components": \[' "$file" 2>/dev/null | tail -1 | cut -d: -f1)
  [ -z "$start_line" ] && start_line=$(grep -n '"components": \[' "$file" 2>/dev/null | tail -1 | cut -d: -f1)
  [ -z "$start_line" ] && { echo "Error: Could not find components array in SBOM." >&2; return 1; }
  sed -n "${start_line},\$p" "$file" | awk '
    BEGIN { in_string=0; escape=0; depth=0; in_array=0; blob=""; started=0 }
    {
      line=$0
      for (i=1; i<=length(line); i++) {
        c = substr(line,i,1)
        if (escape) { escape=0; if (started) blob=blob c }
        else if (in_string) {
          if (c=="\\") escape=1
          else if (c=="\x22") in_string=0
          if (started) blob=blob c
        }
        else if (c=="\x22") { in_string=1; if (started) blob=blob c }
        else if (c=="[" && depth==0) in_array=1
        else if (c=="]" && depth==0 && in_array) exit
        else if (c=="{") {
          depth++
          if (in_array && depth==1) started=1
          if (started) blob=blob c
        }
        else if (c=="}") {
          depth--
          if (started) blob=blob c
          if (depth==0) { printf "%s%c", blob, 30; blob=""; started=0 }
        }
        else if (started) blob=blob c
      }
      if (started) blob=blob "\n"
    }
  '
}

# Record separator for component blobs (ASCII 30 = RS)
readonly S60_RS=$'\x1e'

# Parse components: find OS and package-type blobs. Package types: library, application, framework, container, platform, firmware.
COMPONENTS_TMP="$(mktemp -t components.XXXXXX)"
cleanup_all() {
  rm -f "$COMPONENTS_TMP"
  [ -n "${S60_BATCH_FILE:-}" ] && [ -f "$S60_BATCH_FILE" ] && rm -f "$S60_BATCH_FILE"
  [ -n "${S60_CREATED_TMP_SBOM:-}" ] && [ -n "${TMP_SBOM:-}" ] && [ -f "$TMP_SBOM" ] && rm -f "$TMP_SBOM"
}
trap cleanup_all EXIT
split_sbom_components "$TMP_SBOM" > "$COMPONENTS_TMP"

OS_BLOB=""
PKG_BLOBS=()
while IFS= read -r -d "$S60_RS" blob; do
  [ -z "$blob" ] && continue
  ctype="$(get_json_string "$blob" "type")"
  case "$ctype" in
    operating-system) OS_BLOB="$blob" ;;
    library|application|framework|container|platform|firmware) PKG_BLOBS+=("$blob") ;;
  esac
done < "$COMPONENTS_TMP"

if [ -z "$OS_BLOB" ]; then
  echo "Error: No operating-system component found in SBOM." >&2
  exit 1
fi

sbom_os_bom_ref="$(get_json_string "$OS_BLOB" "bom-ref")"
sbom_os_type="$(get_json_string "$OS_BLOB" "type")"
sbom_os_name="$(get_json_string "$OS_BLOB" "name")"
sbom_os_version="$(get_json_string "$OS_BLOB" "version")"
sbom_os_prop_class="$(get_json_property_value "$OS_BLOB" "aquasecurity:trivy:Class")"
sbom_os_prop_type="$(get_json_property_value "$OS_BLOB" "aquasecurity:trivy:Type")"
sbom_os_family="${sbom_os_name}"
sbom_os_major=""
if [ -n "$sbom_os_version" ] && [[ "$sbom_os_version" =~ ^([0-9]+) ]]; then
  sbom_os_major="${BASH_REMATCH[1]}"
fi

HOST_OS="${host_os:-$sbom_os_name}"
HOST_OS_VERSION="${inv_host_os_version:-$sbom_os_version}"
PKG_COUNT=${#PKG_BLOBS[@]}

# --- Build scan-completed JSON (only non-empty fields)
build_scan_body() {
  local parts=""
  append() { [ -z "$2" ] && return; local _v; _v="$(json_escape "$2")"; [ -n "$parts" ] && parts="$parts,"; parts="$parts\"$1\":\"$_v\""; }
  append "type" "endpoint"
  append "operation" "software-scan-completed"
  append "outcome" "success"
  append "host_name" "$HOST"
  append "app_name" "$APP_NAME_VULN"
  append "inv_pkg_source" "$INV_PKG_SOURCE"
  append "host_os" "$HOST_OS"
  append "environment" "$ENVIRONMENT"
  append "inv_scan_id" "$SCAN_ID"
  append "inv_package_count" "$PKG_COUNT"
  append "inv_host_ip" "$IP"
  append "inv_host_fqdn" "$FQDN"
  append "inv_host_os_version" "$HOST_OS_VERSION"
  append "sbom_os_bom_ref" "$sbom_os_bom_ref"
  append "sbom_os_type" "$sbom_os_type"
  append "sbom_os_name" "$sbom_os_name"
  append "sbom_os_version" "$sbom_os_version"
  append "sbom_os_prop_class" "$sbom_os_prop_class"
  append "sbom_os_prop_type" "$sbom_os_prop_type"
  printf '{%s}' "$parts"
}

# Core POST: send the JSON body in $1 (a file path) to Ingest/Collector. On failure print HTTP status,
# curl exit code, and response body (for debug). Reading the body from a file (--data-binary @file)
# means arbitrarily large batches do not hit the shell argument-length limit (ARG_MAX). $2 is a human
# description used in log/error lines. Returns 0 on success (HTTP 2xx), non-zero otherwise.
_s60_post_file() {
  local body_file="$1"
  local desc="$2"
  local tmp_body tmp_code
  tmp_body="$(mktemp -t s60_curl_body.XXXXXX)"
  tmp_code="$(mktemp -t s60_curl_code.XXXXXX)"
  # -k: always ignore invalid/self-signed SSL so we always attempt to send
  if [ -n "$AUTH_HEADER" ]; then
    curl -sS -k -X POST "$INGEST_URL" -H "Content-Type: application/json" -H "$AUTH_HEADER" --data-binary @"$body_file" -o "$tmp_body" -w "%{http_code}" > "$tmp_code" 2>&1
  else
    curl -sS -k -X POST "$INGEST_URL" -H "Content-Type: application/json" --data-binary @"$body_file" -o "$tmp_body" -w "%{http_code}" > "$tmp_code" 2>&1
  fi
  local curl_exit=$?
  local http_code
  http_code="$(head -1 "$tmp_code" 2>/dev/null | tr -d '\r\n')"
  rm -f "$tmp_code"
  if [ "$curl_exit" -ne 0 ]; then
    echo "Warning: $desc POST failed (curl exit $curl_exit)." >&2
    case "$curl_exit" in
      6)  echo "  Cause: Could not resolve host (DNS failure)." >&2 ;;
      7)  echo "  Cause: Connection refused or failed to connect." >&2 ;;
      28) echo "  Cause: Timeout." >&2 ;;
      35|51|60) echo "  Cause: SSL/TLS certificate verification failed (e.g. self-signed cert, wrong hostname, expired, or corporate proxy). Try: curl -v $INGEST_URL or use a CA bundle that trusts the server." >&2 ;;
      *)  echo "  Possible cause: connection refused, DNS failure, TLS error, or timeout. See 'man curl' for exit code $curl_exit." >&2 ;;
    esac
    [ -s "$tmp_body" ] && echo "  Response body: $(head -c 500 "$tmp_body")" >&2
    rm -f "$tmp_body"
    return 1
  fi
  if [ -z "$http_code" ] || [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
    echo "Warning: $desc POST failed (HTTP $http_code)." >&2
    echo "  Response body: $(head -c 500 "$tmp_body" 2>/dev/null)" >&2
    rm -f "$tmp_body"
    return 1
  fi
  rm -f "$tmp_body"
  return 0
}

# Back-compat wrapper: POST a single JSON body passed as a string (used for the small
# software-scan-completed event). Usage: do_post "description" "$json_body".
# Returns 0 on success (HTTP 2xx), non-zero otherwise.
do_post() {
  local desc="$1"
  local body="$2"
  local tmp rc
  tmp="$(mktemp -t s60_post.XXXXXX)"
  printf '%s' "$body" > "$tmp"
  _s60_post_file "$tmp" "$desc"
  rc=$?
  rm -f "$tmp"
  return $rc
}

echo "POST software-scan-completed (host=$HOST scan_id=$SCAN_ID packages=$PKG_COUNT)"
SCAN_BODY="$(build_scan_body)"
if ! do_post "scan-completed" "$SCAN_BODY"; then
  echo "Continuing with package events."
fi

# --- Build one inventory-package-present per package-type component (flat JSON, optional fields when present)
build_package_payload() {
  local blob="$1"
  local name inv_pkg_version_raw inv_pkg_version_normalised purl ecosystem inv_pkg_arch
  local sbom_bom_ref sbom_comp_type sbom_component_type sbom_supplier_name inv_cpe
  local hash_alg hash_content lic_expression lic_id lic_name
  local trivy_pkg_id trivy_pkg_type trivy_src_name trivy_src_version trivy_src_release trivy_src_epoch trivy_file_path
  name="$(get_json_string "$blob" "name")"
  [ -z "$name" ] && name="$(get_json_string "$blob" "bom-ref")"
  [ -z "$name" ] && name="unknown"
  inv_pkg_version_raw="$(get_json_string "$blob" "version")"
  if [ -n "$inv_pkg_version_raw" ]; then
    inv_pkg_version_normalised="${inv_pkg_version_raw#*:}"
    inv_pkg_version_normalised="${inv_pkg_version_normalised%%-*}"
    [ -z "$inv_pkg_version_normalised" ] && inv_pkg_version_normalised="$inv_pkg_version_raw"
  fi
  purl="$(get_json_string "$blob" "purl")"
  if [ -n "$purl" ] && [[ "$purl" =~ pkg:([^/]+)/ ]]; then
    ecosystem="${BASH_REMATCH[1]}"
  else
    ecosystem="unknown"
  fi
  inv_pkg_arch="$(get_purl_arch "$purl")"
  sbom_bom_ref="$(get_json_string "$blob" "bom-ref")"
  sbom_comp_type="$(get_json_string "$blob" "type")"
  sbom_component_type="$sbom_comp_type"
  sbom_supplier_name="$(get_json_supplier_name "$blob")"
  trivy_pkg_id="$(get_json_property_value "$blob" "aquasecurity:trivy:PkgID")"
  trivy_pkg_type="$(get_json_property_value "$blob" "aquasecurity:trivy:PkgType")"
  trivy_src_name="$(get_json_property_value "$blob" "aquasecurity:trivy:SrcName")"
  trivy_src_version="$(get_json_property_value "$blob" "aquasecurity:trivy:SrcVersion")"
  trivy_src_release="$(get_json_property_value "$blob" "aquasecurity:trivy:SrcRelease")"
  trivy_src_epoch="$(get_json_property_value "$blob" "aquasecurity:trivy:SrcEpoch")"
  trivy_file_path="$(get_json_property_value "$blob" "aquasecurity:trivy:FilePath")"
  inv_cpe="$(get_json_string "$blob" "cpe")"

  # Use actual package name for Portal UI: prefer name from trivy PkgID (e.g. "sssd-client@2.8.2-2.el9.x86_64" -> "sssd-client") when SBOM "name" is vendor (e.g. "Rocky Enterprise Software Foundation")
  if [ -n "$trivy_pkg_id" ] && [[ "$trivy_pkg_id" =~ ^([^@]+)@ ]]; then
    pkg_name_from_id="${BASH_REMATCH[1]}"
    if [ -n "$pkg_name_from_id" ]; then
      name="$pkg_name_from_id"
    fi
  fi
  if [ -z "$name" ] || [ "$name" = "Rocky Enterprise Software Foundation" ] || [ "$name" = "Rocky" ]; then
    [ -n "$trivy_src_name" ] && name="$trivy_src_name"
  fi

  local parts=""
  append() { [ -z "$2" ] && return; local _v; _v="$(json_escape "$2")"; [ -n "$parts" ] && parts="$parts,"; parts="$parts\"$1\":\"$_v\""; }
  append "type" "endpoint"
  append "operation" "inventory-package-present"
  append "outcome" "success"
  append "host_name" "$HOST"
  append "inv_scan_id" "$SCAN_ID"
  append "app_name" "$APP_NAME_VULN"
  append "inv_pkg_name" "$name"
  append "inv_pkg_version_normalised" "$inv_pkg_version_normalised"
  append "inv_pkg_version_raw" "$inv_pkg_version_raw"
  append "inv_purl" "$purl"
  append "inv_pkg_ecosystem" "$ecosystem"
  append "inv_pkg_arch" "$inv_pkg_arch"
  append "inv_pkg_source" "$INV_PKG_SOURCE"
  append "inv_cpe" "$inv_cpe"
  append "sbom_bom_ref" "$sbom_bom_ref"
  append "sbom_comp_type" "$sbom_comp_type"
  append "sbom_component_type" "$sbom_component_type"
  append "sbom_supplier_name" "$sbom_supplier_name"
  append "sbom_os_family" "$sbom_os_family"
  append "sbom_os_version" "$sbom_os_version"
  append "sbom_os_major" "$sbom_os_major"
  # Running kernel release for this host (server uses it to tag kernel pkgs running vs non-running).
  append "host_kernel_running" "$RUNNING_KERNEL_VERSION"

  for i in 0 1 2 3 4; do
    hash_alg="$(get_json_hashN "$blob" "$i" "alg")"
    hash_content="$(get_json_hashN "$blob" "$i" "content")"
    if [ -n "$hash_alg" ] && [ -n "$hash_content" ]; then
      if [ "$i" -eq 0 ]; then
        append "hash_alg" "$hash_alg"
        append "hash_content" "$hash_content"
      else
        append "hash_alg$((i+1))" "$hash_alg"
        append "hash_content$((i+1))" "$hash_content"
      fi
    fi
  done

  lic_expression="$(get_json_licenseN "$blob" 0 "expression")"
  lic_id="$(get_json_licenseN "$blob" 0 "id")"
  lic_name="$(get_json_licenseN "$blob" 0 "name")"
  append "lic_expression" "$lic_expression"
  append "lic_id" "$lic_id"
  append "lic_name" "$lic_name"
  for i in 1 2 3 4; do
    lic_expression="$(get_json_licenseN "$blob" "$i" "expression")"
    lic_id="$(get_json_licenseN "$blob" "$i" "id")"
    if [ -n "$lic_expression" ]; then append "lic_expression$((i+1))" "$lic_expression"; fi
    if [ -n "$lic_id" ]; then append "lic_id$((i+1))" "$lic_id"; fi
  done

  append "trivy_pkg_id" "$trivy_pkg_id"
  append "trivy_pkg_type" "$trivy_pkg_type"
  append "trivy_src_name" "$trivy_src_name"
  append "trivy_src_version" "$trivy_src_version"
  append "trivy_src_release" "$trivy_src_release"
  append "trivy_src_epoch" "$trivy_src_epoch"
  append "trivy_file_path" "$trivy_file_path"

  # sbom_pkgtype for backward compat (same as trivy_pkg_type for OS packages)
  append "sbom_pkgtype" "$trivy_pkg_type"

  # Add any extra aquasecurity:trivy:* properties (trivy_<key>)
  local skip_keys="pkgid pkgtype srcname srcversion srcrelease srcepoch filepath"
  local trivy_regex='\"name\"[[:space:]]*:[[:space:]]*\"aquasecurity:trivy:([^\"]+)\"[^}]*\"value\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"'
  while [[ "$blob" =~ "$trivy_regex" ]]; do
    key="${BASH_REMATCH[1],,}"
    val="${BASH_REMATCH[2]}"
    if [ -n "$val" ] && [[ " $skip_keys " != *" $key "* ]]; then
      [ -n "$parts" ] && parts="$parts,"
      val_esc="$(json_escape "$val")"
      parts="${parts}\"trivy_${key}\":\"${val_esc}\""
    fi
    blob="${blob:${#BASH_REMATCH[0]}}"
  done
  printf '{%s}' "$parts"
}

# --- Send inventory-package-present events in BATCHES, as NDJSON.
# The Secure60 collector and ingest endpoints are a Vector pipeline that frames the HTTP body by
# NEWLINE (decoding codec: json) — i.e. each LINE must be a complete JSON object. So a batch is
# newline-delimited JSON: one compact {...} object per line, NO array brackets and NO commas. We POST
# S60_INGEST_BATCH_SIZE objects per request (default 1000), turning a typical OS scan from
# 1,000-2,000+ requests (one per package) into a handful — far faster and lighter on the ingest tier.
# (A bracketed/multi-line array fails here: Vector parses the lone "[" on line 1 and returns
# HTTP 400 "EOF while parsing a list". NDJSON is also exactly how the collector forwards batches
# upstream to ingest, so the same format works in both collector and ingest modes.)
INGEST_BATCH_SIZE="${S60_INGEST_BATCH_SIZE:-1000}"
case "$INGEST_BATCH_SIZE" in
  ''|*[!0-9]*) INGEST_BATCH_SIZE=1000 ;;
esac
[ "$INGEST_BATCH_SIZE" -ge 1 ] 2>/dev/null || INGEST_BATCH_SIZE=1000

SENT=0
FAILED=0
BATCH_NUM=0
batch_count=0
S60_BATCH_FILE="$(mktemp -t s60_batch.XXXXXX.json)"

# POST the current NDJSON buffer and update counters. No-op when the buffer is empty.
flush_batch() {
  [ "$batch_count" -eq 0 ] && return 0
  BATCH_NUM=$((BATCH_NUM + 1))
  echo "POST inventory-package-present batch #$BATCH_NUM ($batch_count packages) ..."
  if _s60_post_file "$S60_BATCH_FILE" "inventory-package-present batch #$BATCH_NUM ($batch_count pkgs)"; then
    SENT=$((SENT + batch_count))
  else
    FAILED=$((FAILED + batch_count))
  fi
  : > "$S60_BATCH_FILE"
  batch_count=0
}

for blob in "${PKG_BLOBS[@]}"; do
  [ -z "$blob" ] && continue
  payload="$(build_package_payload "$blob")"
  # NDJSON: one compact JSON object per line. Vector frames the body by newline, so each line is
  # decoded as its own event — no array brackets, no commas. build_package_payload emits a single
  # line, and json_escape escapes any embedded newlines, so one event never spans two lines.
  printf '%s\n' "$payload" >> "$S60_BATCH_FILE"
  batch_count=$((batch_count + 1))
  [ "$batch_count" -ge "$INGEST_BATCH_SIZE" ] && flush_batch
done
flush_batch
rm -f "$S60_BATCH_FILE"
S60_BATCH_FILE=""

echo "Done. Scan ID: $SCAN_ID | Packages sent: $SENT | Failed: $FAILED | Batches: $BATCH_NUM"
echo "Validate (api.secure60.io): data_type=events_detail, query operation='software-scan-completed' or 'inventory-package-present', inv_scan_id='$SCAN_ID'"
