#!/usr/bin/env bash
# safedeps — multi-ecosystem dependency install safety gate (CLI).
# Phase 1 advisory gate: OSV (canonical) + CISA KEV (overlay) + GHSA (enrichment)
# → approved-spec ledger write. Hook (scripts/safedeps-pre-guard.sh,
# scripts/safedeps-post-verify.sh) only
# enforces the ledger; this CLI is the only place that talks to providers.

set -euo pipefail

SAFEDEPS_VERSION="2.2.0"

# ---- repo / lib bootstrap ----------------------------------------------------

SAFEDEPS_BIN_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
SAFEDEPS_REPO_DIR=$(cd "${SAFEDEPS_BIN_DIR}/.." && pwd)

# shellcheck source=../lib/providers/providers.sh
source "${SAFEDEPS_REPO_DIR}/lib/providers/providers.sh"
# shellcheck source=../lib/ledger/ledger.sh
source "${SAFEDEPS_REPO_DIR}/lib/ledger/ledger.sh"
# shellcheck source=../lib/npm/closure.sh
source "${SAFEDEPS_REPO_DIR}/lib/npm/closure.sh"

SAFEDEPS_HOME="${SAFEDEPS_HOME:-${HOME}/.safedeps}"
SAFEDEPS_LEDGER_DIR="${SAFEDEPS_LEDGER_DIR:-${SAFEDEPS_HOME}/approved-specs}"
SAFEDEPS_ADVISORY_LOG="${SAFEDEPS_ADVISORY_LOG:-${SAFEDEPS_HOME}/advisory.log}"

# ---- output mode -------------------------------------------------------------

SAFEDEPS_JSON_MODE=0
SAFEDEPS_NO_COLOR=0

sf_color_init() {
  if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]] || [[ "${SAFEDEPS_NO_COLOR}" -eq 1 ]] || [[ -n "${NO_COLOR:-}" ]] || [[ ! -t 1 ]]; then
    C_RED=''; C_YELLOW=''; C_GREEN=''; C_GRAY=''; C_BOLD=''; C_DIM=''; C_RESET=''
  else
    C_RED=$'\033[31m'
    C_YELLOW=$'\033[33m'
    C_GREEN=$'\033[32m'
    C_GRAY=$'\033[90m'
    C_BOLD=$'\033[1m'
    C_DIM=$'\033[2m'
    C_RESET=$'\033[0m'
  fi
}

sf_human() { [[ "${SAFEDEPS_JSON_MODE}" -eq 0 ]]; }

sf_info() { sf_human || return 0; printf '%s· %s%s\n' "${C_GRAY}" "$1" "${C_RESET}"; }
sf_ok()   { sf_human || return 0; printf '%s✓ %s%s\n' "${C_GREEN}" "$1" "${C_RESET}"; }
sf_warn() { sf_human || return 0; printf '%s⚠ %s%s\n' "${C_YELLOW}" "$1" "${C_RESET}"; }
sf_err()  { sf_human || return 0; printf '%s✗ %s%s\n' "${C_RED}" "$1" "${C_RESET}"; }

sf_eprintf() { printf '%s\n' "$*" >&2; }

# Lightweight spinner. No-op in JSON mode or non-tty.
SF_SPINNER_PID=""
sf_spinner_start() {
  sf_human || return 0
  [[ -t 1 ]] || return 0
  local label="$1"
  (
    local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
    local i=0
    while :; do
      printf '\r%s%s%s %s' "${C_GRAY}" "${frames[$i]}" "${C_RESET}" "${label}" >&2
      i=$(( (i + 1) % ${#frames[@]} ))
      sleep 0.08
    done
  ) &
  SF_SPINNER_PID=$!
  disown "${SF_SPINNER_PID}" 2>/dev/null || true
}

sf_spinner_stop() {
  [[ -n "${SF_SPINNER_PID}" ]] || return 0
  kill "${SF_SPINNER_PID}" 2>/dev/null || true
  wait "${SF_SPINNER_PID}" 2>/dev/null || true
  SF_SPINNER_PID=""
  sf_human && printf '\r\033[2K' >&2 || true
}

trap 'sf_spinner_stop' EXIT

# ---- helpers -----------------------------------------------------------------

sf_require_jq() {
  if ! command -v jq >/dev/null 2>&1; then
    sf_eprintf "safedeps: jq is required"
    exit 4
  fi
}

sf_mktemp_evidence() {
  local tmp_dir="${TMPDIR:-/tmp}"

  mkdir -p "${tmp_dir}"
  mktemp "${tmp_dir%/}/safedeps-evidence.XXXXXX"
}

# parse "pkg@range" or "@scope/pkg@range" → pkg / range
sf_parse_pkg_spec() {
  local input="$1"
  local pkg range
  if [[ "${input}" =~ ^(.+)@([^@]+)$ ]]; then
    pkg="${BASH_REMATCH[1]}"
    range="${BASH_REMATCH[2]}"
  else
    pkg="${input}"
    range=""
  fi
  printf '%s\n%s' "${pkg}" "${range}"
}

# heuristic — does this look like a semver range rather than a concrete version?
sf_is_range() {
  local v="$1"
  [[ -z "${v}" ]] && return 1
  case "${v}" in
    \^*|\~*|\**|\>*|\<*|\=*) return 0 ;;
  esac
  [[ "${v}" == *"||"* ]] && return 0
  [[ "${v}" == *" - "* ]] && return 0
  return 1
}

# resolve a range to a concrete installable version via the ecosystem tool.
# Caller is responsible for handling resolve failure (returns 1).
sf_resolve_version() {
  local ecosystem="$1" pkg="$2" range="$3"

  if [[ -z "${range}" ]]; then
    sf_eprintf "safedeps: missing version — usage: <ecosystem> <pkg>@<version|range>"
    return 1
  fi

  if ! sf_is_range "${range}"; then
    printf '%s' "${range}"
    return 0
  fi

  case "${ecosystem}" in
    npm)
      if ! command -v npm >/dev/null 2>&1; then
        sf_eprintf "safedeps: npm CLI required to resolve range '${range}' for ${pkg}"
        return 1
      fi
      local resolved
      resolved=$(npm view "${pkg}@${range}" version --json 2>/dev/null \
        | jq -r 'if type=="array" then .[-1] elif type=="string" then . else empty end' 2>/dev/null) || true
      [[ -n "${resolved}" ]] || {
        sf_eprintf "safedeps: could not resolve ${pkg}@${range} via npm view"
        return 1
      }
      printf '%s' "${resolved}"
      ;;
    *)
      sf_eprintf "safedeps: range resolution for ecosystem '${ecosystem}' not implemented yet — pass a concrete version"
      return 1
      ;;
  esac
}

# Extract fixed version candidates greater than current_version from OSV vulns.
# Echoes unique candidates in ascending version order, bounded for deterministic
# provider retry behavior.
sf_extract_patched_versions() {
  local provider_json_file="$1" current_version="$2"
  local fixed
  fixed=$(jq -r '
    [ .vulnerabilities[]?.affected[]?.ranges[]?.events[]?.fixed // empty ]
    | unique
    | .[]
  ' "${provider_json_file}" 2>/dev/null)

  [[ -z "${fixed}" ]] && return 1

  while IFS= read -r v; do
    [[ -z "${v}" ]] && continue
    # require v > current_version
    local higher
    higher=$(printf '%s\n%s\n' "${v}" "${current_version}" | sort -V | tail -1)
    [[ "${higher}" != "${v}" || "${v}" == "${current_version}" ]] && continue
    printf '%s\n' "${v}"
  done <<< "${fixed}" | sort -Vu | head -20
}

sf_advisory_log() {
  umask 077
  mkdir -p "$(dirname "${SAFEDEPS_ADVISORY_LOG}")"
  printf '[%s] %s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$*" >> "${SAFEDEPS_ADVISORY_LOG}"
}

sf_ledger_has_approval_provenance() {
  local hash="$1"
  local ecosystem="$2"
  local package_name="$3"
  local version="$4"

  [[ -f "${SAFEDEPS_ADVISORY_LOG}" ]] || return 0
  grep -F "check approve" "${SAFEDEPS_ADVISORY_LOG}" 2>/dev/null \
    | grep -F "hash=${hash}" >/dev/null 2>&1 && return 0
  grep -F "check approve" "${SAFEDEPS_ADVISORY_LOG}" 2>/dev/null \
    | grep -F "ecosystem=${ecosystem}" \
    | grep -F "package=${package_name}" \
    | grep -F "version=${version}" >/dev/null 2>&1
}

# Emit either JSON or human text. Both forms describe the same event.
sf_emit_json() {
  if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
    jq -c . <<< "$1"
  fi
}

sf_closure_temp_file() {
  local tmp_dir="${TMPDIR:-/tmp}"

  mkdir -p "${tmp_dir}"
  mktemp "${tmp_dir%/}/safedeps-closure.XXXXXX"
}

sf_npm_closure_for_spec() {
  local package_name="$1"
  local version="$2"
  local closure_file="$3"

  if ! safedeps_npm_resolve_spec_closure "${package_name}" "${version}" > "${closure_file}"; then
    sf_eprintf "safedeps: npm closure resolution failed for ${package_name}@${version}"
    return 1
  fi

  jq -e 'type == "array" and length > 0' "${closure_file}" >/dev/null || {
    sf_eprintf "safedeps: npm closure is empty for ${package_name}@${version}"
    return 1
  }
}

sf_npm_batch_check_closure() {
  local closure_file="$1"
  local batch_file="$2"

  if ! safedeps_providers_query_batch "npm" "${closure_file}" > "${batch_file}"; then
    return 1
  fi
  jq -e 'type == "array"' "${batch_file}" >/dev/null
}

sf_npm_transitive_file_from_closure() {
  local closure_file="$1"
  local package_name="$2"
  local version="$3"
  local output_file="$4"

  jq -c --arg package "${package_name}" --arg version "${version}" '
    [
      .[]
      | select(.package != $package or (.version | tostring) != $version)
      | {ecosystem: "npm", package: .package, version: (.version | tostring)}
    ]
    | unique_by(.ecosystem + "\u0000" + .package + "\u0000" + .version)
    | sort_by(.package, .version)
  ' "${closure_file}" > "${output_file}"
}

sf_npm_evidence_file() {
  local provider_file="$1"
  local closure_file="$2"
  local output_file="$3"

  jq -cn \
    --slurpfile provider "${provider_file}" \
    --slurpfile closure "${closure_file}" \
    '{
      closure_checked: true,
      closure_checked_at: (now | todateiso8601),
      provider: {type: "osv-querybatch", results: $provider[0]},
      closure: $closure[0]
    }' > "${output_file}"
}

sf_ledger_hit_has_npm_closure() {
  local ledger_check="$1"

  jq -e '.approved == true and (.spec.evidence.closure_checked == true)' <<< "${ledger_check}" >/dev/null 2>&1
}

sf_npm_emit_closure_block_json() {
  local result="$1"
  local ecosystem="$2"
  local package_name="$3"
  local range="$4"
  local version="$5"
  local batch_file="$6"

  jq -c \
    --arg result "${result}" \
    --arg ecosystem "${ecosystem}" \
    --arg package "${package_name}" \
    --arg range "${range}" \
    --arg version "${version}" \
    '{
      command:"check",
      ecosystem:$ecosystem,
      package:$package,
      input_range:$range,
      resolved_version:$version,
      result:$result,
      approved:false,
      closure_vulnerabilities: [
        .[]
        | select((.status == "vulnerable") or (.status == "hard_block"))
        | {
            package: .package,
            version: .version,
            direct: (.direct // false),
            result: .status,
            vulnerabilities: (.vulnerabilities // []),
            kev: (.kev // {})
          }
      ]
    }' "${batch_file}"
}

sf_cmd_check_npm_full_closure() {
  local ecosystem="$1"
  local pkg="$2"
  local range="$3"
  local version="$4"
  local closure_file
  local batch_file
  local evidence_file
  local transitive_file
  local direct_status
  local vulnerable_count
  local kev_count

  closure_file=$(sf_closure_temp_file)
  batch_file=$(sf_closure_temp_file)
  evidence_file=$(sf_mktemp_evidence)
  transitive_file=$(sf_closure_temp_file)

  sf_spinner_start "npm closure 해석 중 (${pkg}@${version})"
  if ! sf_npm_closure_for_spec "${pkg}" "${version}" "${closure_file}"; then
    sf_spinner_stop
    rm -f "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
    if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
      jq -nc --arg ecosystem "${ecosystem}" --arg package "${pkg}" --arg range "${range}" --arg version "${version}" \
        '{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, result:"error", approved:false, error:"npm_closure_resolution_failed"}'
    fi
    return 4
  fi
  sf_spinner_stop

  sf_spinner_start "closure 취약점 batch 조회 중 (OSV / KEV)"
  if ! sf_npm_batch_check_closure "${closure_file}" "${batch_file}"; then
    sf_spinner_stop
    sf_err "OSV batch 응답 없음 — fail-closed (closure cache miss + 라이브 실패)"
    sf_advisory_log "check fail-closed npm-closure package=${pkg} version=${version}"
    rm -f "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
    if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
      jq -nc \
        --arg ecosystem "${ecosystem}" --arg package "${pkg}" \
        --arg range "${range}" --arg version "${version}" \
        '{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, result:"provider_unavailable", approved:false, error:"OSV batch primary unavailable; no fresh cache for full closure"}'
    fi
    return 4
  fi
  sf_spinner_stop

  vulnerable_count=$(jq '[.[] | select(.status == "vulnerable")] | length' "${batch_file}")
  kev_count=$(jq '[.[] | select(.status == "hard_block")] | length' "${batch_file}")
  direct_status=$(jq -r --arg package "${pkg}" --arg version "${version}" '
    map(select(.package == $package and .version == $version)) | .[0].status // "missing"
  ' "${batch_file}")

  if [[ "${kev_count}" -gt 0 ]]; then
    local kev_summary
    kev_summary=$(jq -r '[.[] | select(.status == "hard_block") | "\(.package)@\(.version)" + (if .direct then " (direct)" else " (transitive)" end)] | join(", ")' "${batch_file}")
    sf_err "KEV 매칭 closure 감지 — 설치 차단: ${kev_summary}"
    sf_advisory_log "check block(KEV closure) package=${pkg} version=${version} affected=${kev_summary}"
    if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
      sf_npm_emit_closure_block_json "kev_hard_block" "${ecosystem}" "${pkg}" "${range}" "${version}" "${batch_file}"
    fi
    rm -f "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
    return 3
  fi

  if [[ "${vulnerable_count}" -gt 0 ]]; then
    local block_result="closure_vulnerable"
    if [[ "${direct_status}" == "vulnerable" ]]; then
      local direct_json
      local direct_evidence
      local patched_versions=()
      local patched_version
      direct_evidence=$(sf_mktemp_evidence)
      if safedeps_providers_query "${ecosystem}" "${pkg}" "${version}" > "${direct_evidence}"; then
        while IFS= read -r patched_version; do
          [[ -z "${patched_version}" ]] && continue
          patched_versions+=("${patched_version}")
        done < <(sf_extract_patched_versions "${direct_evidence}" "${version}" || true)
      fi

      for patched_version in "${patched_versions[@]+${patched_versions[@]}}"; do
        sf_warn "${pkg}@${version} direct 취약 — 후보 ${patched_version} closure 재조회 중."
        if ! sf_npm_closure_for_spec "${pkg}" "${patched_version}" "${closure_file}"; then
          continue
        fi
        if ! sf_npm_batch_check_closure "${closure_file}" "${batch_file}"; then
          rm -f "${direct_evidence}" "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
          return 4
        fi
        vulnerable_count=$(jq '[.[] | select(.status == "vulnerable" or .status == "hard_block")] | length' "${batch_file}")
        if [[ "${vulnerable_count}" -ne 0 ]]; then
          continue
        fi

        sf_npm_transitive_file_from_closure "${closure_file}" "${pkg}" "${patched_version}" "${transitive_file}"
        sf_npm_evidence_file "${batch_file}" "${closure_file}" "${evidence_file}"
        local spec_json
        spec_json=$(safedeps_ledger_write_approved_spec "${ecosystem}" "${pkg}" "${patched_version}" "${patched_version}" "safedeps-cli" "${evidence_file}" "${SAFEDEPS_LEDGER_DEFAULT_TTL_DAYS}" "${transitive_file}")
        local hash; hash=$(jq -r '.hash' <<< "${spec_json}")
        local expires_at; expires_at=$(jq -r '.expires_at' <<< "${spec_json}")
        local transitive_count; transitive_count=$(jq 'length' "${transitive_file}")
        sf_ok "${pkg}@${patched_version} full closure 승인 (transitive ${transitive_count}, until ${expires_at})"
        sf_info "ledger: ${hash}"
        sf_advisory_log "check approve(patched closure) package=${pkg} version=${patched_version} hash=${hash} transitive=${transitive_count} prev_version=${version}"
        rm -f "${direct_evidence}" "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
        if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
          jq -nc \
            --arg ecosystem "${ecosystem}" --arg package "${pkg}" \
            --arg range "${range}" --arg version "${version}" \
            --arg patched "${patched_version}" \
            --arg hash "${hash}" --arg expires_at "${expires_at}" \
            --argjson transitive_count "${transitive_count}" \
            '{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, suggested_spec:$patched, result:"patched_available", approved:true, spec_hash:$hash, expires_at:$expires_at, transitive_count:$transitive_count, install_hint:("install with " + $package + "@" + $patched)}'
        fi
        return 0
      done
      block_result="cve_unpatched"
      rm -f "${direct_evidence}"
    fi

    local affected_summary
    affected_summary=$(jq -r '[.[] | select(.status == "vulnerable") | "\(.package)@\(.version)" + (if .direct then " (direct)" else " (transitive)" end)] | join(", ")' "${batch_file}")
    sf_warn "closure 에 취약 패키지 감지 — 승인 보류: ${affected_summary}"
    sf_advisory_log "check block(vulnerable closure) package=${pkg} version=${version} affected=${affected_summary}"
    if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
      sf_npm_emit_closure_block_json "${block_result}" "${ecosystem}" "${pkg}" "${range}" "${version}" "${batch_file}"
    fi
    rm -f "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
    return 2
  fi

  sf_npm_transitive_file_from_closure "${closure_file}" "${pkg}" "${version}" "${transitive_file}"
  sf_npm_evidence_file "${batch_file}" "${closure_file}" "${evidence_file}"
  local spec_json
  spec_json=$(safedeps_ledger_write_approved_spec "${ecosystem}" "${pkg}" "${version}" "${range:-${version}}" "safedeps-cli" "${evidence_file}" "${SAFEDEPS_LEDGER_DEFAULT_TTL_DAYS}" "${transitive_file}")
  local hash; hash=$(jq -r '.hash' <<< "${spec_json}")
  local expires_at; expires_at=$(jq -r '.expires_at' <<< "${spec_json}")
  local transitive_count; transitive_count=$(jq 'length' "${transitive_file}")
  sf_ok "${pkg}@${version} full closure 승인 (transitive ${transitive_count}, until ${expires_at})"
  sf_info "ledger: ${hash}"
  sf_advisory_log "check approve(clean closure) package=${pkg} version=${version} hash=${hash} transitive=${transitive_count}"
  rm -f "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
  if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
    jq -nc \
      --arg ecosystem "${ecosystem}" --arg package "${pkg}" \
      --arg range "${range}" --arg version "${version}" \
      --arg hash "${hash}" --arg expires_at "${expires_at}" \
      --argjson transitive_count "${transitive_count}" \
      '{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, result:"clean", approved:true, spec_hash:$hash, expires_at:$expires_at, transitive_count:$transitive_count, install_hint:("install with " + $package + "@" + $version)}'
  fi
  return 0
}

# ---- check -------------------------------------------------------------------

cmd_check() {
  local ecosystem="" pkg_spec=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -h|--help) cmd_help check; return 0 ;;
      --) shift; break ;;
      -*) sf_eprintf "safedeps: unknown option for check: $1"; return 4 ;;
      *)
        if [[ -z "${ecosystem}" ]]; then ecosystem="$1"
        elif [[ -z "${pkg_spec}" ]]; then pkg_spec="$1"
        else sf_eprintf "safedeps: unexpected arg: $1"; return 4
        fi
        shift; continue
        ;;
    esac
    shift
  done

  if [[ -z "${ecosystem}" || -z "${pkg_spec}" ]]; then
    sf_eprintf "usage: safedeps check <ecosystem> <pkg>@<version|range> [--json]"
    return 4
  fi

  sf_require_jq

  local pkg range
  pkg=$(sf_parse_pkg_spec "${pkg_spec}" | sed -n '1p')
  range=$(sf_parse_pkg_spec "${pkg_spec}" | sed -n '2p')

  local version
  sf_spinner_start "버전 해석 중 (${pkg}@${range})"
  if ! version=$(sf_resolve_version "${ecosystem}" "${pkg}" "${range}"); then
    sf_spinner_stop
    sf_err "버전 해석 실패: ${pkg}@${range}"
    if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
      jq -nc --arg ecosystem "${ecosystem}" --arg package "${pkg}" --arg range "${range}" \
        '{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, result:"error", error:"version_resolution_failed"}'
    fi
    return 4
  fi
  sf_spinner_stop

  # Ledger lookup short-circuit
  local ledger_check
  if ledger_check=$(safedeps_ledger_check "${ecosystem}" "${pkg}" "${version}" 2>/dev/null); then
    if [[ "$(jq -r '.approved' <<< "${ledger_check}")" == "true" ]] && { [[ "${ecosystem}" != "npm" ]] || sf_ledger_hit_has_npm_closure "${ledger_check}"; }; then
      local hash approved_at expires_at
      hash=$(jq -r '.hash' <<< "${ledger_check}")
      approved_at=$(jq -r '.spec.approved_at // "n/a"' <<< "${ledger_check}")
      expires_at=$(jq -r '.spec.expires_at // "n/a"' <<< "${ledger_check}")
      sf_ok "${pkg}@${version} 이미 승인됨 (until ${expires_at})"
      sf_info "ledger: ${hash}"
      if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
        jq -nc \
          --arg ecosystem "${ecosystem}" \
          --arg package "${pkg}" \
          --arg range "${range}" \
          --arg version "${version}" \
          --arg hash "${hash}" \
          --arg approved_at "${approved_at}" \
          --arg expires_at "${expires_at}" \
          '{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, result:"already_approved", approved:true, spec_hash:$hash, approved_at:$approved_at, expires_at:$expires_at}'
      fi
      sf_advisory_log "check approve(cache) ecosystem=${ecosystem} package=${pkg} version=${version} hash=${hash}"
      return 0
    fi
  fi

  if [[ "${ecosystem}" == "npm" ]]; then
    sf_cmd_check_npm_full_closure "${ecosystem}" "${pkg}" "${range}" "${version}"
    return $?
  fi

  # Provider query (canonical truth = OSV; KEV overlay; GHSA enrichment)
  local provider_json
  sf_spinner_start "취약점 조회 중 (OSV / KEV / GHSA)"
  if ! provider_json=$(safedeps_providers_query "${ecosystem}" "${pkg}" "${version}"); then
    sf_spinner_stop
    sf_err "OSV primary 응답 없음 — fail-closed (cache miss + 라이브 실패)"
    sf_advisory_log "check fail-closed ecosystem=${ecosystem} package=${pkg} version=${version}"
    if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
      jq -nc \
        --arg ecosystem "${ecosystem}" --arg package "${pkg}" \
        --arg range "${range}" --arg version "${version}" \
        '{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, result:"provider_unavailable", approved:false, error:"OSV primary unavailable; no fresh cache"}'
    fi
    return 4
  fi
  sf_spinner_stop

  local tmp_evidence
  tmp_evidence=$(sf_mktemp_evidence)
  printf '%s' "${provider_json}" > "${tmp_evidence}"

  local status vuln_count kev_exploited
  status=$(jq -r '.status' <<< "${provider_json}")
  vuln_count=$(jq -r '(.vulnerabilities // []) | length' <<< "${provider_json}")
  kev_exploited=$(jq -r '.kev.exploited // false' <<< "${provider_json}")

  case "${status}" in
    hard_block)
      sf_err "KEV 매칭 — ${pkg}@${version} 은 실제 야생에서 exploit 확인됨. 설치 차단."
      local kev_cves
      kev_cves=$(jq -r '[.kev.matches[]?.cveID] | unique | join(", ")' <<< "${provider_json}")
      [[ -n "${kev_cves}" ]] && sf_info "관련 CVE: ${kev_cves}"
      sf_warn "대체 모듈을 검토하세요. 이 spec 은 ledger 에 승인되지 않습니다."
      sf_advisory_log "check block(KEV) ecosystem=${ecosystem} package=${pkg} version=${version} cves=${kev_cves}"
      if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
        jq -c \
          --arg result "kev_hard_block" \
          --arg ecosystem "${ecosystem}" --arg package "${pkg}" \
          --arg range "${range}" --arg version "${version}" \
          '. + {command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, result:$result, approved:false}' <<< "${provider_json}"
      fi
      rm -f "${tmp_evidence}"
      return 3
      ;;

    vulnerable)
      local patched_versions=()
      local patched_version
      while IFS= read -r patched_version; do
        [[ -z "${patched_version}" ]] && continue
        patched_versions+=("${patched_version}")
      done < <(sf_extract_patched_versions "${tmp_evidence}" "${version}" || true)

      if [[ ${#patched_versions[@]} -gt 0 ]]; then
        local narrow_json=""
        local narrow_status=""
        local last_patched=""
        local last_status=""

        for patched_version in "${patched_versions[@]}"; do
          last_patched="${patched_version}"
          sf_warn "${pkg}@${version} 에 ${vuln_count} 개 CVE — 후보 ${patched_version} 재조회 중."
          sf_spinner_start "${patched_version} 재조회 중"
          if ! narrow_json=$(safedeps_providers_query "${ecosystem}" "${pkg}" "${patched_version}"); then
            sf_spinner_stop
            sf_err "${pkg}@${patched_version} 재조회 실패 — fail-closed"
            rm -f "${tmp_evidence}"
            if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
              jq -nc \
                --arg ecosystem "${ecosystem}" --arg package "${pkg}" \
                --arg range "${range}" --arg version "${version}" \
                --arg patched "${patched_version}" \
                '{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, suggested_spec:$patched, result:"provider_unavailable", approved:false}'
            fi
            return 4
          fi
          sf_spinner_stop

          narrow_status=$(jq -r '.status' <<< "${narrow_json}")
          last_status="${narrow_status}"
          if [[ "${narrow_status}" != "clean" ]]; then
            sf_warn "패치 후보 ${patched_version} 도 깨끗하지 않음 (status=${narrow_status}); 다음 후보를 확인합니다."
            continue
          fi

          # approve patched version
          local narrow_evidence
          narrow_evidence=$(sf_mktemp_evidence)
          printf '%s' "${narrow_json}" > "${narrow_evidence}"
          local narrow_range="${patched_version}"
          local spec_json
          spec_json=$(safedeps_ledger_write_approved_spec "${ecosystem}" "${pkg}" "${patched_version}" "${narrow_range}" "safedeps-cli" "${narrow_evidence}")
          rm -f "${narrow_evidence}" "${tmp_evidence}"
          local hash; hash=$(jq -r '.hash' <<< "${spec_json}")
          local expires_at; expires_at=$(jq -r '.expires_at' <<< "${spec_json}")
          sf_ok "${pkg}@${patched_version} 승인 (until ${expires_at})"
          sf_info "ledger: ${hash}"
          sf_advisory_log "check approve(patched) ecosystem=${ecosystem} package=${pkg} version=${patched_version} hash=${hash} prev_version=${version}"
          if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
            jq -nc \
              --arg ecosystem "${ecosystem}" --arg package "${pkg}" \
              --arg range "${range}" --arg version "${version}" \
              --arg patched "${patched_version}" \
              --arg hash "${hash}" --arg expires_at "${expires_at}" \
              '{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, suggested_spec:$patched, result:"patched_available", approved:true, spec_hash:$hash, expires_at:$expires_at, install_hint:("install with " + $package + "@" + $patched)}'
          fi
          return 0
        done

        sf_err "패치 후보를 모두 재조회했지만 clean 후보가 없음 (last=${last_patched}, status=${last_status})"
        rm -f "${tmp_evidence}"
        if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
          jq -nc \
            --arg ecosystem "${ecosystem}" --arg package "${pkg}" \
            --arg range "${range}" --arg version "${version}" \
            --arg patched "${last_patched}" --arg status "${last_status}" \
            '{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, suggested_spec:$patched, result:"patched_still_vulnerable", approved:false, patched_status:$status}'
        fi
        return 2
      else
        sf_warn "${pkg}@${version} 에 ${vuln_count} 개 CVE — 사용 가능한 patch 없음. 승인 보류."
        sf_advisory_log "check warn(no-patch) ecosystem=${ecosystem} package=${pkg} version=${version} vulns=${vuln_count}"
        rm -f "${tmp_evidence}"
        if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
          jq -c \
            --arg ecosystem "${ecosystem}" --arg package "${pkg}" \
            --arg range "${range}" --arg version "${version}" \
            '. + {command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, result:"cve_unpatched", approved:false}' <<< "${provider_json}"
        fi
        return 2
      fi
      ;;

    clean)
      local spec_json
      spec_json=$(safedeps_ledger_write_approved_spec "${ecosystem}" "${pkg}" "${version}" "${range:-${version}}" "safedeps-cli" "${tmp_evidence}")
      rm -f "${tmp_evidence}"
      local hash; hash=$(jq -r '.hash' <<< "${spec_json}")
      local expires_at; expires_at=$(jq -r '.expires_at' <<< "${spec_json}")
      sf_ok "${pkg}@${version} 승인 (until ${expires_at})"
      sf_info "ledger: ${hash}"
      sf_advisory_log "check approve(clean) ecosystem=${ecosystem} package=${pkg} version=${version} hash=${hash}"
      if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
        jq -nc \
          --arg ecosystem "${ecosystem}" --arg package "${pkg}" \
          --arg range "${range}" --arg version "${version}" \
          --arg hash "${hash}" --arg expires_at "${expires_at}" \
          '{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, result:"clean", approved:true, spec_hash:$hash, expires_at:$expires_at, install_hint:("install with " + $package + "@" + $version)}'
      fi
      return 0
      ;;

    *)
      sf_err "예상치 못한 provider status: ${status}"
      rm -f "${tmp_evidence}"
      return 4
      ;;
  esac
}

# ---- ledger ------------------------------------------------------------------

cmd_ledger() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -h|--help) cmd_help ledger; return 0 ;;
      *) sf_eprintf "safedeps: unexpected arg for ledger: $1"; return 4 ;;
    esac
  done

  sf_require_jq
  safedeps_ledger_init

  local entries=()
  if [[ -d "${SAFEDEPS_LEDGER_DIR}" ]]; then
    while IFS= read -r -d '' f; do
      entries+=("${f}")
    done < <(find "${SAFEDEPS_LEDGER_DIR}" -maxdepth 1 -name '*.json' -type f -print0 2>/dev/null)
  fi

  if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
    local merged='[]'
    for f in "${entries[@]+${entries[@]}}"; do
      local now_iso
      now_iso=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
      merged=$(jq -c --slurpfile spec "${f}" --arg now "${now_iso}" '
        . + [
          ($spec[0]) as $s |
          {
            hash: $s.hash,
            ecosystem: $s.ecosystem,
            package: $s.package,
            version: $s.version,
            version_range: $s.version_range,
            approved_at: $s.approved_at,
            expires_at: $s.expires_at,
            approved_by: $s.approved_by,
            expired: ($s.expires_at < $now),
            revoked: (($s.revoked_at // null) != null)
          }
        ]
      ' <<< "${merged}")
    done
    jq -nc --argjson specs "${merged}" '{command:"ledger", count: ($specs | length), specs: $specs}'
    return 0
  fi

  if [[ ${#entries[@]} -eq 0 ]]; then
    sf_info "approved-specs 비어있음 (${SAFEDEPS_LEDGER_DIR})"
    return 0
  fi

  printf '%s%-8s %-12s %-40s %-14s %-22s %-22s %s%s\n' \
    "${C_BOLD}" "STATE" "ECOSYSTEM" "PACKAGE" "VERSION" "APPROVED" "EXPIRES" "HASH" "${C_RESET}"
  for f in "${entries[@]+${entries[@]}}"; do
    local hash ecosystem pkg version approved_at expires_at revoked_at expired state state_color
    hash=$(jq -r '.hash // ""' "${f}")
    ecosystem=$(jq -r '.ecosystem // ""' "${f}")
    pkg=$(jq -r '.package // ""' "${f}")
    version=$(jq -r '.version // ""' "${f}")
    approved_at=$(jq -r '.approved_at // ""' "${f}")
    expires_at=$(jq -r '.expires_at // ""' "${f}")
    revoked_at=$(jq -r '.revoked_at // ""' "${f}")

    expired=0
    if [[ -n "${expires_at}" ]]; then
      local exp_epoch now_epoch
      if exp_epoch=$(safedeps_ledger_epoch "${expires_at}" 2>/dev/null); then
        now_epoch=$(date +%s)
        [[ "${exp_epoch}" -le "${now_epoch}" ]] && expired=1
      fi
    fi

    if [[ -n "${revoked_at}" ]]; then
      state="REVOKED"; state_color="${C_GRAY}"
    elif [[ "${expired}" -eq 1 ]]; then
      state="EXPIRED"; state_color="${C_YELLOW}"
    else
      state="ACTIVE";  state_color="${C_GREEN}"
    fi

    printf '%s%-8s%s %-12s %-40s %-14s %-22s %-22s %s\n' \
      "${state_color}" "${state}" "${C_RESET}" \
      "${ecosystem}" "${pkg}" "${version}" \
      "${approved_at}" "${expires_at}" "${hash}"
  done
}

# ---- revoke ------------------------------------------------------------------

cmd_revoke() {
  local arg1="" arg2="" reason=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -h|--help) cmd_help revoke; return 0 ;;
      --reason) reason="${2:-}"; shift 2; continue ;;
      --reason=*) reason="${1#--reason=}"; shift; continue ;;
      -*) sf_eprintf "safedeps: unknown option for revoke: $1"; return 4 ;;
      *)
        if [[ -z "${arg1}" ]]; then arg1="$1"
        elif [[ -z "${arg2}" ]]; then arg2="$1"
        else sf_eprintf "safedeps: unexpected arg: $1"; return 4
        fi
        shift; continue
        ;;
    esac
    shift
  done

  if [[ -z "${arg1}" ]]; then
    sf_eprintf "usage: safedeps revoke <hash> | <ecosystem> <pkg>@<version> | <pkg>@<version>  [--reason <reason>]"
    return 4
  fi

  sf_require_jq
  safedeps_ledger_init
  reason="${reason:-cli-revoke}"

  local target_file=""
  if [[ "${arg1}" == sha256:* ]]; then
    target_file=$(safedeps_ledger_path_for_hash "${arg1}")
    [[ -f "${target_file}" ]] || { sf_err "ledger entry 없음: ${arg1}"; return 1; }
  else
    # one or two args. Two = ecosystem + pkg@version. One = pkg@version (scan).
    if [[ -n "${arg2}" ]]; then
      local pkg version
      pkg=$(sf_parse_pkg_spec "${arg2}" | sed -n '1p')
      version=$(sf_parse_pkg_spec "${arg2}" | sed -n '2p')
      [[ -n "${version}" ]] || { sf_eprintf "safedeps: revoke needs pkg@version, got '${arg2}'"; return 4; }
      target_file=$(safedeps_ledger_path "${arg1}" "${pkg}" "${version}")
      [[ -f "${target_file}" ]] || { sf_err "ledger entry 없음: ${arg1} ${pkg}@${version}"; return 1; }
    else
      local pkg version
      pkg=$(sf_parse_pkg_spec "${arg1}" | sed -n '1p')
      version=$(sf_parse_pkg_spec "${arg1}" | sed -n '2p')
      [[ -n "${version}" ]] || { sf_eprintf "safedeps: revoke needs pkg@version or hash, got '${arg1}'"; return 4; }
      local matches=()
      while IFS= read -r -d '' f; do
        local p v
        p=$(jq -r '.package // ""' "${f}")
        v=$(jq -r '.version // ""' "${f}")
        if [[ "${p}" == "${pkg}" && "${v}" == "${version}" ]]; then
          matches+=("${f}")
        fi
      done < <(find "${SAFEDEPS_LEDGER_DIR}" -maxdepth 1 -name '*.json' -type f -print0 2>/dev/null)
      case "${#matches[@]}" in
        0) sf_err "ledger entry 없음: ${pkg}@${version}"; return 1 ;;
        1) target_file="${matches[0]}" ;;
        *) sf_err "${pkg}@${version} 가 여러 ecosystem 에서 매칭됨 — ecosystem 을 명시하세요"; return 4 ;;
      esac
    fi
  fi

  local ecosystem pkg version
  ecosystem=$(jq -r '.ecosystem' "${target_file}")
  pkg=$(jq -r '.package' "${target_file}")
  version=$(jq -r '.version' "${target_file}")

  local revoked_json
  revoked_json=$(safedeps_ledger_revoke "${ecosystem}" "${pkg}" "${version}" "${reason}")
  sf_advisory_log "revoke ecosystem=${ecosystem} package=${pkg} version=${version} reason=${reason}"
  sf_ok "취소: ${ecosystem} ${pkg}@${version}"
  sf_info "reason: ${reason}"

  if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
    jq -c --arg reason "${reason}" \
      '{command:"revoke", revoked:true, reason:$reason, spec: .}' <<< "${revoked_json}"
  fi
}

# ---- re-check ----------------------------------------------------------------

cmd_recheck() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -h|--help) cmd_help re-check; return 0 ;;
      *) sf_eprintf "safedeps: unexpected arg for re-check: $1"; return 4 ;;
    esac
  done

  sf_require_jq
  safedeps_ledger_init

  local entries=()
  while IFS= read -r -d '' f; do
    entries+=("${f}")
  done < <(find "${SAFEDEPS_LEDGER_DIR}" -maxdepth 1 -name '*.json' -type f -print0 2>/dev/null)

  local checked=0 still_clean=0
  local newly_vuln_arr='[]' kev_hit_arr='[]' revoked_arr='[]' suspected_forgery_arr='[]'

  for f in "${entries[@]+${entries[@]}}"; do
    local ecosystem pkg version revoked_at hash
    ecosystem=$(jq -r '.ecosystem' "${f}")
    pkg=$(jq -r '.package' "${f}")
    version=$(jq -r '.version' "${f}")
    revoked_at=$(jq -r '.revoked_at // ""' "${f}")
    hash=$(jq -r '.hash // ""' "${f}")
    [[ -n "${revoked_at}" ]] && continue
    if [[ -f "${SAFEDEPS_ADVISORY_LOG}" ]] && ! sf_ledger_has_approval_provenance "${hash}" "${ecosystem}" "${pkg}" "${version}"; then
      sf_warn "  provenance 없음 — 위조 의심 flag (revoke 안 함)"
      suspected_forgery_arr=$(jq -c \
        --arg ecosystem "${ecosystem}" --arg package "${pkg}" --arg version "${version}" --arg hash "${hash}" \
        '. + [{ecosystem:$ecosystem, package:$package, version:$version, hash:$hash, reason:"missing_advisory_log_approval"}]' <<< "${suspected_forgery_arr}")
    fi
    checked=$(( checked + 1 ))

    sf_info "재검증 ${ecosystem} ${pkg}@${version}"
    local pj
    if ! pj=$(safedeps_providers_query "${ecosystem}" "${pkg}" "${version}" 2>/dev/null); then
      sf_warn "  provider 응답 없음 — skip"
      continue
    fi
    local s; s=$(jq -r '.status' <<< "${pj}")
    case "${s}" in
      clean)
        still_clean=$(( still_clean + 1 ))
        ;;
      vulnerable|hard_block)
        local reason="re-check ${s}"
        safedeps_ledger_revoke "${ecosystem}" "${pkg}" "${version}" "${reason}" >/dev/null
        sf_advisory_log "re-check revoke ecosystem=${ecosystem} package=${pkg} version=${version} status=${s}"
        if [[ "${s}" == "hard_block" ]]; then
          sf_err "  KEV 매칭 → revoke"
          kev_hit_arr=$(jq -c \
            --arg ecosystem "${ecosystem}" --arg package "${pkg}" --arg version "${version}" \
            '. + [{ecosystem:$ecosystem, package:$package, version:$version, status:"hard_block"}]' <<< "${kev_hit_arr}")
        else
          sf_warn "  새 CVE 매치 → revoke"
          newly_vuln_arr=$(jq -c \
            --arg ecosystem "${ecosystem}" --arg package "${pkg}" --arg version "${version}" \
            '. + [{ecosystem:$ecosystem, package:$package, version:$version, status:"vulnerable"}]' <<< "${newly_vuln_arr}")
        fi
        revoked_arr=$(jq -c \
          --arg ecosystem "${ecosystem}" --arg package "${pkg}" --arg version "${version}" --arg reason "${reason}" \
          '. + [{ecosystem:$ecosystem, package:$package, version:$version, reason:$reason}]' <<< "${revoked_arr}")
        ;;
    esac
  done

  if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
    jq -nc \
      --argjson newly_vulnerable "${newly_vuln_arr}" \
      --argjson kev_hit "${kev_hit_arr}" \
      --argjson revoked "${revoked_arr}" \
      --argjson suspected_forgery "${suspected_forgery_arr}" \
      --argjson checked "${checked}" \
      --argjson still_clean "${still_clean}" \
      '{command:"re-check", checked:$checked, still_clean:$still_clean, newly_vulnerable:$newly_vulnerable, kev_hit:$kev_hit, revoked:$revoked, suspected_forgery:$suspected_forgery}'
    return 0
  fi

  sf_info "검증 완료: ${checked} 개 중 ${still_clean} 개 clean"
  local nv kv
  nv=$(jq -r 'length' <<< "${newly_vuln_arr}")
  kv=$(jq -r 'length' <<< "${kev_hit_arr}")
  local fg
  fg=$(jq -r 'length' <<< "${suspected_forgery_arr}")
  [[ "${nv}" -gt 0 ]] && sf_warn "새 CVE 매치로 ${nv} 개 revoke"
  [[ "${kv}" -gt 0 ]] && sf_err  "KEV 매치로 ${kv} 개 revoke"
  [[ "${fg}" -gt 0 ]] && sf_warn "approval provenance 없는 ledger entry ${fg} 개 flag"
}

# ---- migrate -----------------------------------------------------------------

cmd_migrate() {
  local keep_legacy=0
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --keep-legacy) keep_legacy=1; shift ;;
      -h|--help) cmd_help migrate; return 0 ;;
      *) sf_eprintf "safedeps: unexpected arg for migrate: $1"; return 4 ;;
    esac
  done

  if ! command -v node >/dev/null 2>&1; then
    sf_eprintf "safedeps: node is required for state migration"
    return 4
  fi

  local migrate_script="${SAFEDEPS_REPO_DIR}/scripts/install/migrate-safedeps-state.mjs"
  [[ -f "${migrate_script}" ]] || {
    sf_eprintf "safedeps: migration script not found: ${migrate_script}"
    return 4
  }

  if [[ "${keep_legacy}" -eq 1 ]]; then
    node "${migrate_script}" --keep-legacy
  else
    node "${migrate_script}"
  fi
}

# ---- release-time gates (absorbed from security-release-gates) ---------------

cmd_gates() {
  local script="${SAFEDEPS_REPO_DIR}/scripts/release-gates.sh"
  if [[ ! -f "${script}" ]]; then
    sf_eprintf "safedeps: release-gates script not found: ${script}"
    return 4
  fi
  # default subcommand is 'run'
  if [[ "${1:-}" == "run" ]]; then shift; fi
  exec bash "${script}" "$@"
}

cmd_scan() {
  exec bash "${SAFEDEPS_REPO_DIR}/lib/gates/scan.sh" "$@"
}

cmd_audit() {
  exec bash "${SAFEDEPS_REPO_DIR}/lib/gates/audit.sh" "$@"
}

cmd_hooks() {
  exec bash "${SAFEDEPS_REPO_DIR}/lib/gates/hooks.sh" "$@"
}

# ---- help / version ----------------------------------------------------------

cmd_version() {
  if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
    jq -nc --arg v "${SAFEDEPS_VERSION}" '{command:"version", version:$v}'
  else
    printf 'safedeps %s\n' "${SAFEDEPS_VERSION}"
  fi
}

cmd_help() {
  local topic="${1:-}"
  case "${topic}" in
    check)
      cat <<'EOF'
safedeps check <ecosystem> <pkg>@<version|range> [--json]

  Phase 1 advisory gate. Query OSV (canonical) + CISA KEV (overlay) + GHSA (enrichment),
  classify, and — when clean or patched_available — write an approved-spec entry.

  ecosystem: npm | pypi | crates.io | go | rubygems | maven | nuget
  exit codes: 0 clean/approved · 1 reserved · 2 cve_unpatched · 3 kev_hard_block · 4 input/provider error
EOF
      ;;
    ledger)
      cat <<'EOF'
safedeps ledger [--json]

  List approved specs from ~/.safedeps/approved-specs/.
  Columns: STATE ECOSYSTEM PACKAGE VERSION APPROVED EXPIRES HASH.
EOF
      ;;
    revoke)
      cat <<'EOF'
safedeps revoke <hash> | <ecosystem> <pkg>@<version> | <pkg>@<version> [--reason <reason>] [--json]

  Mark an approved-spec entry as revoked. The hook will then block install
  commands for that spec until it is re-approved with `safedeps check`.
EOF
      ;;
    re-check)
      cat <<'EOF'
safedeps re-check [--json]

  Re-query providers for every active approved spec.
  Auto-revoke entries that newly match a CVE or KEV.
EOF
      ;;
    migrate)
      cat <<'EOF'
safedeps migrate [--keep-legacy]

  Migrate legacy ~/.npm-reorg-guard state into ~/.safedeps.
  By default the legacy directory is archived to remove the old active truth path.
EOF
      ;;
    *)
      cat <<EOF
safedeps ${SAFEDEPS_VERSION} — multi-ecosystem install safety gate

USAGE
  safedeps <command> [args] [--json] [--no-color]

COMMANDS
  check <ecosystem> <pkg>@<version|range>   Phase 1 advisory gate + ledger approve.
  ledger                                    List approved specs.
  revoke <hash | pkg@version>               Revoke an approved spec.
  re-check                                  Re-query providers for all approved specs.
  migrate                                   Migrate legacy npm-reorg-guard state.
  gates [run]                               Release-time repo gates: secret scan, dep audit, hook/CI check.
  scan secrets [--repo|--worktree|--staged] Run gitleaks secret scan (repo profile aware).
  audit [npm]                               Run npm lockfile audit.
  hooks <install|check>                     Install/verify repo-local git hooks.
  help [command]                            Show help.
  version                                   Print version.

GLOBAL FLAGS
  --json       Machine-readable JSON output (stable schema).
  --no-color   Disable ANSI colors.

EXIT CODES
  0  clean / approved
  2  CVE found without an upgrade path
  3  CISA KEV match — hard block
  4  input error or provider unavailable (fail-closed)

ENV
  SAFEDEPS_HOME           default ~/.safedeps
  SAFEDEPS_LEDGER_DIR     default \$SAFEDEPS_HOME/approved-specs
  GITHUB_TOKEN            optional, used for GHSA enrichment
EOF
      ;;
  esac
}

# ---- main dispatch -----------------------------------------------------------

main() {
  local positional=()
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --json) SAFEDEPS_JSON_MODE=1; shift ;;
      --no-color) SAFEDEPS_NO_COLOR=1; shift ;;
      -h|--help)
        positional+=("help")
        shift
        ;;
      --version)
        positional+=("version")
        shift
        ;;
      --) shift; while [[ $# -gt 0 ]]; do positional+=("$1"); shift; done ;;
      *) positional+=("$1"); shift ;;
    esac
  done

  sf_color_init

  if [[ ${#positional[@]} -eq 0 ]]; then
    cmd_help
    return 0
  fi

  local cmd="${positional[0]}"
  set -- "${positional[@]:1}"

  case "${cmd}" in
    check)    cmd_check    "$@" ;;
    ledger)   cmd_ledger   "$@" ;;
    revoke)   cmd_revoke   "$@" ;;
    re-check|recheck) cmd_recheck "$@" ;;
    migrate)  cmd_migrate  "$@" ;;
    gates)    cmd_gates    "$@" ;;
    scan)     cmd_scan     "$@" ;;
    audit)    cmd_audit    "$@" ;;
    hooks)    cmd_hooks    "$@" ;;
    help)     cmd_help     "$@" ;;
    version)  cmd_version ;;
    *)
      sf_eprintf "safedeps: unknown command '${cmd}'. Try 'safedeps help'."
      return 4
      ;;
  esac
}

main "$@"
