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

# Cividesk platform inventory pingback helper.
#
# This command is safe to run on instances with CiviCRM, Drupal, WordPress,
# or any combination of them. It detects available tools, collects inventory
# data, and posts it to Cividesk platform mode on the relay.
# It does not depend on or override CiviCRM core's VersionCheck pingback URL.
#
# Environment variables used when present:
# - APP and ENV: derive Website Type/app_location as APP@ENV, e.g. cividesk@prod.
# - APP_URL: canonical site URL/host candidates used for relay matching.
# - CIVICRM_DATABASE, DRUPAL_DATABASE, WORDPRESS_DATABASE: optional DB names for richer collection.
# - MYSQL_HOST, MYSQL_HOSTNAME, MYSQL_PORT, MYSQL_USERNAME, MYSQL_USER, MYSQL_PASSWORD.
# - VIRTUAL_HOST, CIVIDESK_SITE_URL, SITE_URL, PRIMARY_URL: optional existing URL fallbacks.
# - PINGBACK_RELAY_URL and PINGBACK_BASE_URL: optional manual/test overrides only.

PINGBACK_HELPER_VERSION="1.0.0"
RELAY_URL="${PINGBACK_RELAY_URL:-https://cividesk.com/pingback.php?platform_pingback=1}"
DRY_RUN=0
BASE_URL_OVERRIDE=""
update_check_index=0
component_update_index=0

usage() {
  cat <<'USAGE'
Usage: cividesk-platform-pingback [OPTIONS]

Collect platform inventory and send it to Cividesk platform pingback mode.

Options:
  --relay-url URL       Relay endpoint. Defaults to PINGBACK_RELAY_URL or Cividesk prod.
  --base-url URL        Manual/test canonical site URL override.
  --dry-run             Print collected request fields instead of posting.
  -V, --version         Show helper version and exit.
  -h, --help            Show this help.

Examples:
  cividesk-platform-pingback

  PINGBACK_RELAY_URL="https://cividesk-dev.themis.cividesk.net/pingback.php?platform_pingback=1" \
  PINGBACK_BASE_URL="nb.firstliteracy.org" \
  cividesk-platform-pingback
USAGE
}

while [ "$#" -gt 0 ]; do
  case "$1" in
    --relay-url)
      RELAY_URL="${2:-}"
      shift 2
      ;;
    --base-url)
      BASE_URL_OVERRIDE="${2:-}"
      shift 2
      ;;
    --dry-run)
      DRY_RUN=1
      shift
      ;;
    -V|--version)
      printf 'cividesk-platform-pingback %s\n' "$PINGBACK_HELPER_VERSION"
      exit 0
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    *)
      echo "Unknown option: $1" >&2
      usage >&2
      exit 2
      ;;
  esac
done

MYSQL_HOST_VALUE="${MYSQL_HOST:-${MYSQL_HOSTNAME:-localhost}}"
MYSQL_PORT_VALUE="${MYSQL_PORT:-3306}"
MYSQL_USER_VALUE="${MYSQL_USERNAME:-${MYSQL_USER:-}}"
MYSQL_PASSWORD_VALUE="${MYSQL_PASSWORD:-}"
CIVICRM_DB="${CIVICRM_DATABASE:-}"
DRUPAL_DB="${DRUPAL_DATABASE:-}"
WORDPRESS_DB="${WORDPRESS_DATABASE:-}"

curl_args=(-fsS -X POST "$RELAY_URL")
declare -a dry_run_fields=()
declare -a url_candidates=()

add_field() {
  local key="$1"
  local value="${2:-}"

  if [ -n "$value" ]; then
    curl_args+=(--data-urlencode "${key}=${value}")
    dry_run_fields+=("${key}=${value}")
  fi
}

# Keep native update payloads intentionally small and safe. The receiver also
# sanitizes, but the helper avoids transmitting raw URLs or secret-like values.
safe_update_text() {
  local value="${1:-}"
  local max_len="${2:-255}"
  local lower=""

  value="$(printf '%s' "$value" | tr '\000-\010\013\014\016-\037\177' ' ')"
  value="$(printf '%s' "$value" | sed 's/^ *//;s/ *$//')"
  [ -n "$value" ] || return 0

  lower="$(lower_ascii "$value")"
  case "$lower" in
    *http://*|*https://*|*://*)
      return 0
      ;;
  esac

  if printf '%s' "$lower" | grep -Eq '(^|[[:space:],;&?])(token|apikey|api_key|secret|signature|password|passwd|license)([[:space:]]*=|:)'; then
    return 0
  fi

  printf '%s' "${value:0:max_len}"
}

add_url_candidate() {
  local value="${1:-}"
  local normalized=""

  normalized="$(normalize_base_url "$value")"
  if [ -n "$normalized" ]; then
    url_candidates+=("$normalized")
  fi
}

mysql_scalar() {
  local db="$1"
  local sql="$2"

  if [ -z "$db" ] || [ -z "$MYSQL_USER_VALUE" ]; then
    return 0
  fi

  MYSQL_PWD="$MYSQL_PASSWORD_VALUE" mysql \
    -h"$MYSQL_HOST_VALUE" \
    -P"$MYSQL_PORT_VALUE" \
    -u"$MYSQL_USER_VALUE" \
    -N -B \
    -D "$db" \
    -e "$sql" 2>/dev/null | head -n 1 || true
}

mysql_rows() {
  local db="$1"
  local sql="$2"

  if [ -z "$db" ] || [ -z "$MYSQL_USER_VALUE" ]; then
    return 0
  fi

  MYSQL_PWD="$MYSQL_PASSWORD_VALUE" mysql \
    -h"$MYSQL_HOST_VALUE" \
    -P"$MYSQL_PORT_VALUE" \
    -u"$MYSQL_USER_VALUE" \
    -N -B \
    -D "$db" \
    -e "$sql" 2>/dev/null || true
}

php_md5() {
  php -r 'echo md5($argv[1]);' "$1"
}

first_nonempty() {
  for value in "$@"; do
    if [ -n "${value:-}" ]; then
      printf '%s' "$value"
      return 0
    fi
  done
}

normalize_base_url() {
  local value="${1:-}"

  value="${value%%,*}"
  value="${value#http://}"
  value="${value#https://}"
  value="${value#//}"
  value="${value%%/*}"
  value="${value%%:*}"
  value="${value%.}"
  value="${value%/}"

  printf '%s' "$value"
}

split_url_candidates() {
  local value="${1:-}"
  local item=""

  value="${value//;/,}"
  value="${value//|/,}"

  IFS=',' read -r -a parts <<< "$value"
  for item in "${parts[@]:-}"; do
    item="$(printf '%s' "$item" | sed 's/^ *//;s/ *$//')"
    add_url_candidate "$item"
  done
}

site_base_url_from_wordpress() {
  local value=""

  if command -v wp >/dev/null 2>&1; then
    value="$(wp option get home --allow-root 2>/dev/null || wp option get home 2>/dev/null || true)"

    if [ -z "$value" ]; then
      value="$(wp option get siteurl --allow-root 2>/dev/null || wp option get siteurl 2>/dev/null || true)"
    fi
  fi

  normalize_base_url "$value"
}

site_base_url_from_civicrm() {
  local value=""

  if command -v cv >/dev/null 2>&1; then
    value="$(cv ev 'echo defined("CIVICRM_UF_BASEURL") ? CIVICRM_UF_BASEURL : "";' 2>/dev/null || true)"
  fi

  normalize_base_url "$value"
}

site_base_url_from_drupal() {
  local value=""

  if command -v drush >/dev/null 2>&1; then
    value="$(drush status --field=uri 2>/dev/null || true)"

    if [ -z "$value" ]; then
      value="$(drush status 2>/dev/null | awk -F: 'tolower($1) ~ /uri|site uri/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print $2; exit}' || true)"
    fi
  fi

  case "$value" in
    http://*|https://*|*.*)
      normalize_base_url "$value"
      ;;
    *)
      printf ''
      ;;
  esac
}

drupal_version_from_drush() {
  local value=""

  if ! command -v drush >/dev/null 2>&1; then
    printf ''
    return 0
  fi

  value="$(drush status --field=drupal-version 2>/dev/null || true)"

  if [ -z "$value" ]; then
    value="$(drush status --format=json 2>/dev/null | php -r '
      $data = json_decode(stream_get_contents(STDIN), true);
      if (!is_array($data)) exit;
      foreach (["drupal-version", "drupal_version", "Drupal version"] as $key) {
        if (!empty($data[$key])) { echo $data[$key]; exit; }
      }
    ' 2>/dev/null || true)"
  fi

  if [ -z "$value" ]; then
    value="$(drush status 2>/dev/null | awk -F: 'tolower($1) ~ /drupal version/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print $2; exit}' || true)"
  fi

  printf '%s' "$value"
}

drupal_enabled_modules_csv() {
  if ! command -v drush >/dev/null 2>&1; then
    return 0
  fi

  drush pm:list --status=enabled --type=module --fields=name,version --format=csv 2>/dev/null \
    || drush pm-list --status=enabled --type=module --fields=name,version --format=csv 2>/dev/null \
    || true
}

find_composer_root() {
  local candidates=()
  local dir=""
  local seen=""

  candidates+=("$PWD")
  candidates+=("$PWD/sites/default")
  candidates+=("/var/www/html")
  candidates+=("/var/www/html/web")
  candidates+=("/var/www/html/web/sites/default")

  dir="$PWD"
  while [ -n "$dir" ] && [ "$dir" != "/" ]; do
    candidates+=("$dir")
    dir="$(dirname "$dir")"
  done

  for dir in "${candidates[@]}"; do
    [ -n "$dir" ] || continue
    case " $seen " in
      *" $dir "*) continue ;;
    esac
    seen="$seen $dir"

    if [ -f "$dir/composer.json" ]; then
      printf '%s' "$dir"
      return 0
    fi
  done

  printf ''
}

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

derive_app_location() {
  local app=""
  local env=""
  local slug=""

  app="$(lower_ascii "${APP:-}")"
  env="$(lower_ascii "${ENV:-}")"

  if [ -n "$app" ] && [ -n "$env" ]; then
    printf '%s@%s' "$app" "$env"
    return 0
  fi

  # Fallback for existing app metadata such as APP_SLUG=cividesk-prod.
  slug="$(lower_ascii "${APP_SLUG:-}")"
  case "$slug" in
    *-prod|*-stage|*-dev)
      env="${slug##*-}"
      app="${slug%-${env}}"
      if [ -n "$app" ] && [ -n "$env" ]; then
        printf '%s@%s' "$app" "$env"
        return 0
      fi
      ;;
  esac

  printf ''
}


native_checks_enabled() {
  # Native update checks are enabled by default. Keep an explicit off switch
  # for emergency rollback or focused troubleshooting without changing cron.
  case "$(lower_ascii "${PINGBACK_NATIVE_UPDATE_CHECKS:-1}")" in
    0|false|no|off)
      return 1
      ;;
  esac

  return 0
}

checked_at_utc() {
  date -u '+%Y-%m-%dT%H:%M:%SZ'
}

now_ms() {
  php -r 'echo (int) floor(microtime(true) * 1000);'
}

add_update_check() {
  local ecosystem="$1"
  local scope="$2"
  local source="$3"
  local status="$4"
  local duration_ms="${5:-0}"
  local error_code="${6:-}"
  local error_message="${7:-}"
  local n="${update_check_index:-0}"

  ecosystem="$(safe_update_text "$ecosystem" 32)"
  scope="$(safe_update_text "$scope" 64)"
  source="$(safe_update_text "$source" 64)"
  status="$(safe_update_text "$status" 32)"
  error_code="$(safe_update_text "$error_code" 64)"
  error_message="$(safe_update_text "$error_message" 512)"

  add_field "update_checks[$n][ecosystem]" "$ecosystem"
  add_field "update_checks[$n][scope]" "$scope"
  add_field "update_checks[$n][source]" "$source"
  add_field "update_checks[$n][status]" "$status"
  add_field "update_checks[$n][checked_at]" "$(checked_at_utc)"
  add_field "update_checks[$n][duration_ms]" "$duration_ms"
  add_field "update_checks[$n][safe_error_code]" "$error_code"
  add_field "update_checks[$n][safe_error_message]" "$error_message"
  update_check_index=$((n + 1))
}

add_component_update() {
  local ecosystem="$1"
  local component_type="$2"
  local component_key="$3"
  local installed_version="${4:-}"
  local available_version="${5:-}"
  local enabled="${6:-}"
  local update_status="${7:-unknown}"
  local security_status="${8:-unknown}"
  local source="${9:-}"
  local security_severity="${10:-unknown}"
  local security_advisory_count="${11:-0}"
  local security_identifiers="${12:-}"
  local security_title="${13:-}"
  local security_affected_versions="${14:-}"
  local n="${component_update_index:-0}"

  ecosystem="$(safe_update_text "$ecosystem" 32)"
  component_type="$(safe_update_text "$component_type" 64)"
  component_key="$(safe_update_text "$component_key" 255)"
  installed_version="$(safe_update_text "$installed_version" 255)"
  available_version="$(safe_update_text "$available_version" 255)"
  enabled="$(safe_update_text "$enabled" 8)"
  update_status="$(safe_update_text "$update_status" 32)"
  security_status="$(safe_update_text "$security_status" 64)"
  security_severity="$(safe_update_text "$security_severity" 32)"
  security_advisory_count="$(safe_update_text "$security_advisory_count" 10)"
  security_identifiers="$(safe_update_text "$security_identifiers" 512)"
  security_title="$(safe_update_text "$security_title" 512)"
  security_affected_versions="$(safe_update_text "$security_affected_versions" 255)"
  source="$(safe_update_text "$source" 64)"

  [ -n "$component_key" ] || return 0

  add_field "updates[$n][ecosystem]" "$ecosystem"
  add_field "updates[$n][component_type]" "$component_type"
  add_field "updates[$n][component_key]" "$component_key"
  add_field "updates[$n][installed_version]" "$installed_version"
  add_field "updates[$n][available_version]" "$available_version"
  add_field "updates[$n][enabled]" "$enabled"
  add_field "updates[$n][update_status]" "$update_status"
  add_field "updates[$n][security_status]" "$security_status"
  add_field "updates[$n][security_severity]" "$security_severity"
  add_field "updates[$n][security_advisory_count]" "$security_advisory_count"
  add_field "updates[$n][security_identifiers]" "$security_identifiers"
  add_field "updates[$n][security_title]" "$security_title"
  add_field "updates[$n][security_affected_versions]" "$security_affected_versions"
  add_field "updates[$n][source]" "$source"
  component_update_index=$((n + 1))
}

native_run() {
  local timeout_seconds="$1"
  shift
  local start_ms=""
  local end_ms=""
  local rc=0
  local out=""
  local err=""

  out="$(mktemp)"
  err="$(mktemp)"
  start_ms="$(now_ms)"

  set +e
  timeout "${timeout_seconds}s" "$@" >"$out" 2>"$err"
  rc=$?
  set -e

  end_ms="$(now_ms)"
  RUN_STDOUT="$out"
  RUN_STDERR="$err"
  RUN_DURATION_MS=$((end_ms - start_ms))

  if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
    RUN_STATUS="timeout"
    RUN_ERROR_CODE="timeout"
  elif [ "$rc" -eq 0 ]; then
    RUN_STATUS="success"
    RUN_ERROR_CODE=""
  elif grep -Eiq 'not found|not defined|no commands defined|unknown command|does not exist|has been removed|could not find a composer.json' "$err"; then
    RUN_STATUS="unsupported"
    RUN_ERROR_CODE="unsupported"
  else
    RUN_STATUS="failed"
    RUN_ERROR_CODE="command_failed"
  fi
}

cleanup_native_run() {
  rm -f "${RUN_STDOUT:-}" "${RUN_STDERR:-}"
}

json_file_valid() {
  php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(json_last_error() === JSON_ERROR_NONE ? 0 : 1);' "$1" 2>/dev/null
}

collect_wordpress_component_updates() {
  local timeout_seconds="$1"
  local component_type="$2"
  local scope="$3"
  local enabled_statuses="$4"
  local emit_update_payload="${5:-1}"
  local safe_fields="name,status,version,update,update_version,auto_update"

  # Keep full WordPress bootstrap here. Premium/private plugins can populate or
  # filter update metadata during bootstrap (for example Elementor Pro), so
  # --skip-plugins would make the central update inventory incomplete.
  native_run "$timeout_seconds" wp "$component_type" list --fields="$safe_fields" --format=json --allow-root
  if [ "$RUN_STATUS" != "success" ]; then
    cleanup_native_run
    native_run "$timeout_seconds" wp "$component_type" list --fields=name,status,version,update,update_version --format=json --allow-root
  fi

  # Reuse the plugin update query for the active WordPress plugin inventory.
  # This removes the previous second `wp plugin list --status=active` bootstrap.
  if [ "$component_type" = "plugin" ]; then
    if [ "$RUN_STATUS" = "success" ]; then
      while IFS='|' read -r plugin_name plugin_version; do
        [ -n "${plugin_name:-}" ] || continue
        add_field "cms_extensions[$idx][cms]" WordPress
        add_field "cms_extensions[$idx][type]" plugin
        add_field "cms_extensions[$idx][name]" "$plugin_name"
        add_field "cms_extensions[$idx][version]" "${plugin_version:-}"
        add_field "cms_extensions[$idx][enabled]" 1
        idx=$((idx + 1))
      done < <(php -r '
        $data = json_decode(file_get_contents($argv[1]), true);
        if (!is_array($data)) exit(2);
        foreach ($data as $item) {
          if (!is_array($item)) continue;
          if (strtolower(trim((string) ($item["status"] ?? ""))) !== "active") continue;
          $name = trim((string) ($item["name"] ?? ""));
          if ($name === "") continue;
          $version = trim((string) ($item["version"] ?? ""));
          $clean = static function ($value) { return str_replace(["|", "\t", "\r", "\n"], " ", (string) $value); };
          echo $clean($name) . "|" . $clean($version) . "\n";
        }
      ' "$RUN_STDOUT" 2>/dev/null || true)
    else
      # Preserve the previous inventory fallback if richer JSON is unavailable.
      while IFS=, read -r plugin_name plugin_version; do
        [ -n "${plugin_name:-}" ] || continue
        [ "$plugin_name" = "name" ] && continue
        add_field "cms_extensions[$idx][cms]" WordPress
        add_field "cms_extensions[$idx][type]" plugin
        add_field "cms_extensions[$idx][name]" "$plugin_name"
        add_field "cms_extensions[$idx][version]" "${plugin_version:-}"
        add_field "cms_extensions[$idx][enabled]" 1
        idx=$((idx + 1))
      done < <(wp plugin list --status=active --fields=name,version --format=csv --allow-root 2>/dev/null || wp plugin list --status=active --fields=name,version --format=csv 2>/dev/null || true)
    fi
  fi

  if [ "$emit_update_payload" -eq 1 ]; then
    add_update_check wordpress "$scope" wp-cli "$RUN_STATUS" "$RUN_DURATION_MS" "$RUN_ERROR_CODE" ""

    if [ "$RUN_STATUS" = "success" ]; then
      while IFS='|' read -r name enabled version update_version update_status; do
        [ -n "${name:-}" ] || continue
        add_component_update wordpress "$component_type" "$name" "$version" "$update_version" "$enabled" "$update_status" unknown wp-cli
      done < <(php -r '
        $data = json_decode(file_get_contents($argv[1]), true);
        $enabledStatuses = array_filter(explode(",", (string) $argv[2]));
        $componentType = (string) $argv[3];
        $emptyVersions = ["", "current", "none", "off", "no", "false", "0", "unknown", "n/a", "na"];
        $noUpdateStates = ["", "none", "current", "off", "no", "false", "0"];
        $dropins = ["advanced-cache.php", "object-cache.php", "db.php", "db-error.php", "install.php", "maintenance.php", "sunrise.php", "blog-deleted.php", "blog-inactive.php", "blog-suspended.php", "fatal-error-handler.php"];
        if (!is_array($data)) exit(2);
        foreach ($data as $item) {
          $name = trim((string)($item["name"] ?? ""));
          if ($name === "") continue;

          $status = strtolower(trim((string)($item["status"] ?? "")));
          if ($componentType === "plugin" && ($status === "dropin" || in_array(strtolower($name), $dropins, true))) {
            continue;
          }

          if (!in_array($status, $enabledStatuses, true)) {
            continue;
          }

          $enabled = "1";
          $version = trim((string)($item["version"] ?? ""));
          $update = strtolower(trim((string)($item["update"] ?? "")));
          $updateVersion = trim((string)($item["update_version"] ?? ""));
          $updateVersionLower = strtolower($updateVersion);

          if (in_array($updateVersionLower, $emptyVersions, true)) {
            $updateVersion = "";
          }

          if ($update === "available" || $update === "update_available" || $updateVersion !== "") {
            $updateStatus = "update_available";
          }
          elseif (in_array($update, $noUpdateStates, true)) {
            $updateStatus = "current";
            $updateVersion = "";
          }
          else {
            $updateStatus = "unknown";
            $updateVersion = "";
          }

          $clean = static function ($value) { return str_replace(["|", "\t", "\r", "\n"], " ", (string) $value); };
          echo implode("|", array_map($clean, [$name, $enabled, $version, $updateVersion, $updateStatus])) . "\n";
        }
      ' "$RUN_STDOUT" "$enabled_statuses" "$component_type" 2>/dev/null || true)
    fi
  fi

  cleanup_native_run
}

collect_wordpress_native_updates() {
  local timeout_seconds="${PINGBACK_NATIVE_UPDATE_TIMEOUT:-30}"
  local force_arg=""
  local core_version="${WORDPRESS_VERSION:-}"
  local available=""
  local status="current"

  if ! command -v wp >/dev/null 2>&1; then
    add_update_check wordpress core wp-cli unsupported 0 unsupported "wp-cli not available"
    add_update_check wordpress plugins wp-cli unsupported 0 unsupported "wp-cli not available"
    add_update_check wordpress themes wp-cli unsupported 0 unsupported "wp-cli not available"
    return 0
  fi

  if [ "${PINGBACK_WORDPRESS_FORCE_UPDATE_REFRESH:-0}" = "1" ]; then
    force_arg="--force-check"
  fi

  native_run "$timeout_seconds" wp core check-update $force_arg --format=json --allow-root
  add_update_check wordpress core wp-cli "$RUN_STATUS" "$RUN_DURATION_MS" "$RUN_ERROR_CODE" ""
  if [ "$RUN_STATUS" = "success" ]; then
    available="$(php -r '$data=json_decode(file_get_contents($argv[1]), true); if (is_array($data) && isset($data[0]["version"])) echo $data[0]["version"];' "$RUN_STDOUT" 2>/dev/null || true)"
    if [ -n "$available" ]; then
      status="update_available"
    fi
    add_component_update wordpress core wordpress "$core_version" "$available" 1 "$status" unknown wp-cli
  fi
  cleanup_native_run

  # Plugin inventory/update state was already collected in the WordPress
  # inventory pass, avoiding a second full WordPress bootstrap here.
  collect_wordpress_component_updates "$timeout_seconds" theme themes active,active-network
}

collect_civicrm_native_updates() {
  local timeout_seconds="${PINGBACK_NATIVE_UPDATE_TIMEOUT:-30}"

  if [ "$CIVICRM_AVAILABLE" -ne 1 ] || ! command -v cv >/dev/null 2>&1; then
    add_update_check civicrm system_check cv unsupported 0 unsupported "cv or CiviCRM database not available"
    return 0
  fi

  native_run "$timeout_seconds" cv api4 System.check --out=json
  add_update_check civicrm system_check cv "$RUN_STATUS" "$RUN_DURATION_MS" "$RUN_ERROR_CODE" ""
  cleanup_native_run
}

collect_drupal_composer_audit_components() {
  local json_file="$1"

  while IFS='|' read -r package count severity identifiers title affected_versions; do
    [ -n "${package:-}" ] || continue
    add_component_update drupal package "$package" "" "" 1 unknown security_update_available composer-audit "$severity" "$count" "$identifiers" "$title" "$affected_versions"
  done < <(php -r '
    $data = json_decode(file_get_contents($argv[1]), true);
    if (!is_array($data) || empty($data["advisories"]) || !is_array($data["advisories"])) exit;

    $rank = ["unknown" => 0, "low" => 1, "medium" => 2, "high" => 3, "critical" => 4];
    $clean = static function ($value, $limit = 512) {
      $value = str_replace(["|", "\t", "\r", "\n"], " ", (string) $value);
      $value = preg_replace("/\s+/", " ", trim($value));
      if (preg_match("#https?://|://#i", $value)) return "";
      return substr($value, 0, $limit);
    };

    foreach ($data["advisories"] as $package => $items) {
      if (!is_array($items)) continue;

      $count = 0;
      $maxSeverity = "unknown";
      $identifiers = [];
      $affected = [];
      $title = "";

      foreach ($items as $item) {
        if (!is_array($item)) continue;
        $count++;
        $severity = strtolower(trim((string) ($item["severity"] ?? "unknown")));
        if (!isset($rank[$severity])) $severity = "unknown";
        if ($rank[$severity] > $rank[$maxSeverity]) {
          $maxSeverity = $severity;
          $title = (string) ($item["title"] ?? "");
        }

        foreach (["cve", "advisoryId"] as $key) {
          $value = trim((string) ($item[$key] ?? ""));
          if ($value !== "") $identifiers[$value] = true;
        }

        $affectedVersion = trim((string) ($item["affectedVersions"] ?? ""));
        if ($affectedVersion !== "") $affected[$affectedVersion] = true;
      }

      if ($count < 1) continue;

      echo implode("|", [
        $clean($package, 255),
        (string) $count,
        $maxSeverity,
        $clean(implode(", ", array_keys($identifiers)), 512),
        $clean($title, 512),
        $clean(implode(", ", array_keys($affected)), 255),
      ]) . "\n";
    }
  ' "$json_file" 2>/dev/null || true)
}

drupal_enabled_module_names() {
  drupal_enabled_modules_csv | awk -F, '
    NR == 1 && (tolower($1) == "name" || $1 == "Name") { next }
    $1 != "" { print $1 }
  ' | tr '\n' ',' || true
}

collect_drupal_composer_outdated_components() {
  local timeout_seconds="$1"
  local composer_root="${2:-}"
  local enabled_modules=""

  if ! command -v composer >/dev/null 2>&1 || [ -z "$composer_root" ]; then
    add_update_check drupal composer_outdated composer unsupported 0 unsupported "composer outdated not available"
    return 0
  fi

  enabled_modules="$(drupal_enabled_module_names)"

  native_run "$timeout_seconds" composer --working-dir="$composer_root" outdated "drupal/*" --format=json --no-interaction
  if [ "$RUN_STATUS" = "failed" ] && json_file_valid "$RUN_STDOUT"; then
    RUN_STATUS="success"
    RUN_ERROR_CODE=""
  fi

  add_update_check drupal composer_outdated composer "$RUN_STATUS" "$RUN_DURATION_MS" "$RUN_ERROR_CODE" ""

  if [ "$RUN_STATUS" = "success" ] && json_file_valid "$RUN_STDOUT"; then
    while IFS='|' read -r package installed latest update_status; do
      [ -n "${package:-}" ] || continue
      [ -n "${latest:-}" ] || continue
      add_component_update drupal package "$package" "$installed" "$latest" 1 "$update_status" unknown composer-outdated
    done < <(php -r '
      $data = json_decode(file_get_contents($argv[1]), true);
      $enabled = array_filter(explode(",", (string) ($argv[2] ?? "")));
      $enabled = array_fill_keys($enabled, true);
      $filterEnabled = !empty($enabled);
      if (!is_array($data)) exit(0);

      $rows = $data["installed"] ?? [];
      if (!is_array($rows)) exit(0);

      $clean = static function ($value) {
        return str_replace(["|", "\t", "\r", "\n"], " ", (string) $value);
      };

      foreach ($rows as $item) {
        if (!is_array($item)) continue;

        $name = trim((string) ($item["name"] ?? ""));
        if ($name === "" || strpos($name, "drupal/") !== 0) {
          continue;
        }

        $packageName = substr($name, 7);
        $isCorePackage = strpos($name, "drupal/core") === 0;
        if (!$isCorePackage && $filterEnabled && empty($enabled[$packageName])) {
          continue;
        }

        $installed = trim((string) ($item["version"] ?? ""));
        $latest = trim((string) ($item["latest"] ?? ""));
        if ($latest === "" || $latest === $installed) {
          continue;
        }

        echo implode("|", array_map($clean, [
          $name,
          $installed,
          $latest,
          "update_available",
        ])) . "\n";
      }
    ' "$RUN_STDOUT" "$enabled_modules" 2>/dev/null || true)
  fi

  cleanup_native_run
}

collect_drupal_native_updates() {
  local timeout_seconds="${PINGBACK_NATIVE_UPDATE_TIMEOUT:-30}"
  local composer_root=""

  if ! command -v drush >/dev/null 2>&1; then
    add_update_check drupal security drush unsupported 0 unsupported "drush not available"
  else
    native_run "$timeout_seconds" drush pm:security --format=json
    if [ "$RUN_STATUS" = "failed" ] && json_file_valid "$RUN_STDOUT"; then
      RUN_STATUS="success"
      RUN_ERROR_CODE=""
    fi
    add_update_check drupal security drush "$RUN_STATUS" "$RUN_DURATION_MS" "$RUN_ERROR_CODE" ""
    cleanup_native_run
  fi

  composer_root="$(find_composer_root)"

  if ! command -v composer >/dev/null 2>&1 || [ -z "$composer_root" ]; then
    add_update_check drupal composer_audit composer unsupported 0 unsupported "composer audit not available"
  else
    native_run "$timeout_seconds" sh -c 'cd "$1" && composer audit --format=json --no-interaction' sh "$composer_root"
    if [ "$RUN_STATUS" = "failed" ] && json_file_valid "$RUN_STDOUT"; then
      RUN_STATUS="success"
      RUN_ERROR_CODE=""
    fi
    add_update_check drupal composer_audit composer "$RUN_STATUS" "$RUN_DURATION_MS" "$RUN_ERROR_CODE" ""

    if json_file_valid "$RUN_STDOUT"; then
      collect_drupal_composer_audit_components "$RUN_STDOUT"
    fi

    cleanup_native_run
  fi

  collect_drupal_composer_outdated_components "$timeout_seconds" "$composer_root"
}

APP_LOCATION="$(derive_app_location)"

VIRTUAL_HOST_VALUE="${VIRTUAL_HOST:-}"
VIRTUAL_HOST_VALUE="${VIRTUAL_HOST_VALUE%%,*}"

WP_BASE_URL="$(site_base_url_from_wordpress)"
CIVICRM_BASE_URL="$(site_base_url_from_civicrm)"
DRUPAL_BASE_URL="$(site_base_url_from_drupal)"

BASE_URL="$(first_nonempty \
  "$BASE_URL_OVERRIDE" \
  "${PINGBACK_BASE_URL:-}" \
  "$WP_BASE_URL" \
  "$CIVICRM_BASE_URL" \
  "$DRUPAL_BASE_URL" \
  "${APP_URL:-}" \
  "${CIVIDESK_SITE_URL:-}" \
  "${SITE_URL:-}" \
  "${PRIMARY_URL:-}" \
  "$VIRTUAL_HOST_VALUE")"
BASE_URL="$(normalize_base_url "$BASE_URL")"

for value in \
  "$BASE_URL" \
  "$WP_BASE_URL" \
  "$CIVICRM_BASE_URL" \
  "$DRUPAL_BASE_URL" \
  "${APP_URL:-}" \
  "${CIVIDESK_SITE_URL:-}" \
  "${SITE_URL:-}" \
  "${PRIMARY_URL:-}" \
  "${VIRTUAL_HOST:-}"; do
  split_url_candidates "$value"
done

IDENTITY="platform:${BASE_URL:-$(hostname)}:${APP_LOCATION}:${CIVICRM_DB}:${DRUPAL_DB}:${WORDPRESS_DB}"
SID="$(php_md5 "$IDENTITY")"
HASH="$SID"

add_field platform_pingback 1
add_field mode platform
add_field sid "$SID"
add_field hash "$HASH"
add_field base_url "$BASE_URL"
add_field app_location "$APP_LOCATION"

idx=0
for candidate in "${url_candidates[@]:-}"; do
  [ -n "$candidate" ] || continue
  add_field "url_candidates[$idx]" "$candidate"
  idx=$((idx + 1))
done

CIVICRM_AVAILABLE=0
DRUPAL_AVAILABLE=0
WORDPRESS_AVAILABLE=0
if [ -n "$CIVICRM_DB" ]; then
  CIVICRM_VERSION="$(mysql_scalar "$CIVICRM_DB" "SELECT version FROM civicrm_domain ORDER BY id LIMIT 1")"
else
  CIVICRM_VERSION=""
fi

if [ -n "$CIVICRM_VERSION" ]; then
  CIVICRM_AVAILABLE=1
fi

PHP_VERSION_VALUE="$(php -r 'echo PHP_VERSION;')"
add_field PHP "$PHP_VERSION_VALUE"

# CiviCRM inventory. This keeps the existing full inventory behavior when a
# CiviCRM DB is available, but it is optional for Drupal/WordPress-only sites.
if [ "$CIVICRM_AVAILABLE" -eq 1 ]; then
  add_field inventory_mode full
  add_field version "$CIVICRM_VERSION"

  idx=0
  while IFS=$'\t' read -r full_name is_active; do
    [ -n "${full_name:-}" ] || continue
    add_field "extensions[$idx][name]" "$full_name"
    add_field "extensions[$idx][enabled]" "${is_active:-1}"
    idx=$((idx + 1))
  done < <(mysql_rows "$CIVICRM_DB" "SELECT full_name, is_active FROM civicrm_extension WHERE is_active = 1 ORDER BY full_name")

  idx=0
  while IFS=$'\t' read -r processor_name; do
    [ -n "${processor_name:-}" ] || continue
    add_field "payment_processors[$idx][type_title]" "$processor_name"
    idx=$((idx + 1))
  done < <(mysql_rows "$CIVICRM_DB" "SELECT DISTINCT COALESCE(ppt.title, ppt.name, pp.name) FROM civicrm_payment_processor pp LEFT JOIN civicrm_payment_processor_type ppt ON ppt.id = pp.payment_processor_type_id WHERE pp.is_active = 1 ORDER BY 1")
else
  add_field inventory_mode cms_only
fi

# Drupal inventory.
if command -v drush >/dev/null 2>&1; then
  DRUPAL_VERSION="$(drupal_version_from_drush)"
  if [ -n "$DRUPAL_VERSION" ]; then
    DRUPAL_AVAILABLE=1
    add_field uf Drupal
    add_field ufv "$DRUPAL_VERSION"
  fi

  idx=0
  while IFS=, read -r module_name module_version; do
    [ -n "${module_name:-}" ] || continue
    [ "$module_name" = "Name" ] && continue
    [ "$module_name" = "name" ] && continue
    add_field "cms_extensions[$idx][cms]" Drupal
    add_field "cms_extensions[$idx][type]" module
    add_field "cms_extensions[$idx][name]" "$module_name"
    add_field "cms_extensions[$idx][version]" "${module_version:-}"
    add_field "cms_extensions[$idx][enabled]" 1
    idx=$((idx + 1))
  done < <(drupal_enabled_modules_csv)
fi

# WordPress inventory.
if command -v wp >/dev/null 2>&1; then
  WORDPRESS_VERSION="$(wp --skip-plugins --skip-themes core version --allow-root 2>/dev/null || wp --skip-plugins --skip-themes core version 2>/dev/null || wp core version --allow-root 2>/dev/null || wp core version 2>/dev/null || true)"
  if [ -n "$WORDPRESS_VERSION" ]; then
    WORDPRESS_AVAILABLE=1
    add_field uf WordPress
    add_field ufv "$WORDPRESS_VERSION"
  fi

  idx=0
  wordpress_plugin_updates=0
  if [ "$WORDPRESS_AVAILABLE" -eq 1 ] && native_checks_enabled; then
    wordpress_plugin_updates=1
  fi
  collect_wordpress_component_updates "${PINGBACK_NATIVE_UPDATE_TIMEOUT:-30}" plugin plugins active,active-network,must-use "$wordpress_plugin_updates"
fi

if native_checks_enabled; then
  if [ "$WORDPRESS_AVAILABLE" -eq 1 ]; then
    collect_wordpress_native_updates
  fi

  if [ "$DRUPAL_AVAILABLE" -eq 1 ]; then
    collect_drupal_native_updates
  fi

  if [ "$CIVICRM_AVAILABLE" -eq 1 ]; then
    collect_civicrm_native_updates
  fi
fi

if [ "$DRY_RUN" -eq 1 ]; then
  printf '%s\n' "${dry_run_fields[@]}"
  exit 0
fi

if curl "${curl_args[@]}"; then
  printf '\nPingback sent successfully to: %s\n' "$RELAY_URL"
else
  status=$?
  printf '\nPingback failed for: %s (exit code: %s)\n' "$RELAY_URL" "$status" >&2
  exit "$status"
fi
