#!/usr/bin/env bash
#
# no-unstable-deps - Agora - unit 001, T-301.
#
# Fails if the package declares any dependency that is not a stable release, or
# relaxes Composer's stability settings. Marketplace rule, restated in CLAUDE.md
# rule 1: no dev / alpha / beta / rc, ever.
#
# WHY THERE IS NO ALLOW-LIST
# --------------------------
# The starter kit is not excluded - it is out of scope. This script parses the
# `require`, `require-dev`, `minimum-stability` and `prefer-stable` keys of
# `composer.json` with `jq`. Prose that merely mentions an unstable version is
# unreachable from here by construction. There is no allow-list, and adding one
# requires a signed D-NNN.
#
# I-005 feared a false positive on `drupal/drupal_cms_site_template_base`, which
# has only branches and no stable release. That false positive can only be
# produced by a `grep -r` over the repository colliding with the documentation
# that describes the kit (IDIOMS.md, the research, plan.md section 6). Narrowing
# by SCOPE instead of by exclusion means the hole does not exist: there is no
# list that can later be widened.
#
# Contract:
#   scope:    <exact list of files inspected>
#   scanned:  <N> file(s)    - N must be > 0 or this script exits 1
#   findings: <M>            - each printed as  path:line  reason
#   exit 0 iff M == 0
#
# An empty scope, a missing tool or an unreadable file is a FAILURE, never a
# skip (I-007). Every exclusion is printed on every run; there are none today.
#
#
# House rules for tests/bin/ (T-321(c)): `wc -l` never `grep -c` for a compared
# value; numeric guards defaulted ONLY where zero means FAIL; grep rc >= 2 is an
# error, never "no match"; never `-F` with `-i`. The CANONICAL copy of the block,
# with the reasoning and the I-031 asymmetry spelled out, lives in the header of
# tests/bin/no-ci-allow-dev. Read it before adding a counter here.
#
# Usage: tests/bin/no-unstable-deps   (run from anywhere; it cd's to the repo root)

set -u
# No `set -e`: a finding must be recorded and counted, not abort the run.

# `CDPATH= cd` is an env-var prefix, not an assignment; shellcheck misreads it.
# The idiom matches tests/bin/gate-a-wave1.sh deliberately.
# shellcheck disable=SC1007
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
# shellcheck disable=SC1007
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
cd "$REPO_ROOT" || { echo "FATAL: cannot cd to repo root $REPO_ROOT"; exit 1; }

C_BAD=""; C_OFF=""
if [ -t 1 ]; then C_BAD=$(printf '\033[31m'); C_OFF=$(printf '\033[0m'); fi

TAB=$(printf '\t')

fatal() { printf 'FATAL: %s\n' "$1" >&2; exit 1; }

# ------------------------------------------------------- grep's third state --
# T-316 / I-027. `grep` has THREE exit states, not two: 0 = matched, 1 = no
# match, >= 2 = grep itself FAILED (invalid pattern, unreadable file, crash).
# Inferring "no findings" from "no stdout" turns every grep error into a green
# run - a scanner that cannot scan reporting that everything is fine. Every grep
# below whose result decides a finding therefore reads its exit status, and
# >= 2 aborts the run. It is never downgraded to a warning and never counted as
# a finding: a failure to scan is not a clean scan.
#
# Some of those greps run inside a command substitution, where `exit` ends only
# the subshell. They also leave a marker file, honoured by assert_grep_ok()
# before anything is summarised, so a failure inside a subshell can never
# surface as `findings: 0`.
grep_failed() { # <rc> <pattern> <subject>
  printf 'FATAL: grep failed (exit %s) on %s while matching: %s\n' "$1" "$3" "$2" >&2
  printf 'grep exit %s on %s while matching: %s\n' "$1" "$3" "$2" >> "$GREP_ERR_FILE"
  exit 1
}

assert_grep_ok() {
  [ -s "$GREP_ERR_FILE" ] || return 0
  printf '\nFATAL: the scan did not complete - grep failed:\n' >&2
  sed 's/^/  /' "$GREP_ERR_FILE" >&2
  printf 'Nothing was scanned: this run has NO result, which is not a clean result. (T-316 / I-027)\n' >&2
  exit 1
}

# ---------------------------------------------------------------- preflight --
# Name the missing tool loudly. A silent skip here would turn this invariant
# into an `exit 0` that proves nothing.
command -v jq >/dev/null 2>&1 || fatal "required tool not found: jq"

MANIFEST="composer.json"
LOCK="composer.lock"

[ -f "$MANIFEST" ] || fatal "$MANIFEST not found in $REPO_ROOT (empty scope)"
[ -r "$MANIFEST" ] || fatal "$MANIFEST is not readable"
jq -e . "$MANIFEST" >/dev/null 2>&1 || fatal "$MANIFEST is not valid JSON"

FINDINGS_FILE=$(mktemp -t no-unstable-deps.XXXXXX) || fatal "cannot create a temp file"
GREP_ERR_FILE=$(mktemp -t no-unstable-deps-grep.XXXXXX) || fatal "cannot create a temp file"
trap 'rm -f "$FINDINGS_FILE" "$GREP_ERR_FILE"' EXIT

SCANNED=0
SCOPE_LIST="$MANIFEST"

# Findings are appended to a file and counted from it at report time, never
# accumulated in a variable: some of the checks below run inside a command
# substitution, and a counter incremented in a subshell is silently lost. One
# finding is always exactly one line.
finding() { # <file> <line> <reason>
  printf '  %s%s:%s%s  %s\n' "$C_BAD" "$1" "$2" "$C_OFF" "$3" >> "$FINDINGS_FILE"
}

count_findings() {
  wc -l < "$FINDINGS_FILE" | tr -d '[:space:]'
}

# Line number of the first literal occurrence of <string> in <file>; 0 when the
# string cannot be located (the reason text still identifies the offender).
# jq finds the offender, grep -n locates it: jq does not report line numbers.
# grep is NOT piped straight into `head` here: the pipeline's exit status would
# be `head`'s, and grep's own status is precisely what this function must read.
line_of() { # <string> <file>
  _out=$(grep -n -F -- "$1" "$2" 2>/dev/null)
  _rc=$?
  # rc 1 = not located, which is the documented `0` answer below. rc >= 2 = grep
  # broke, and a fabricated line number would bury that.
  [ "$_rc" -ge 2 ] && grep_failed "$_rc" "$1" "$2"
  _n=$(printf '%s\n' "$_out" | head -1 | cut -d: -f1)
  if [ -n "${_n:-}" ]; then printf '%s' "$_n"; else printf '0'; fi
}

# A version constraint is unstable if ANY of its alternatives is unstable. It is
# tested per alternative rather than over the whole string, so that `^1.0 || ^2.0`
# is never confused with `^1.0 || 2.0.0-beta1`.
#   -dev followed by end or a non-alphanumeric  ->  1.2.x-dev
#   -alpha / -beta / -rc                        ->  2.1.0-beta1, 1.5.0-rc1
#   a leading dev-                              ->  dev-main
#   @dev / @alpha / @beta / @RC stability flags ->  ^1.0@beta
UNSTABLE_ERE='(-dev([^0-9A-Za-z]|$)|-alpha|-beta|-rc|^dev-|@(dev|alpha|beta|rc))'

is_unstable() { # <single alternative>
  printf '%s' "$1" | grep -qiE "$UNSTABLE_ERE"
  _rc=$?
  # rc >= 2 would be read by the caller as "not unstable": an unstable
  # constraint waved through by a grep that never ran. Abort instead.
  [ "$_rc" -ge 2 ] && grep_failed "$_rc" "$UNSTABLE_ERE" "constraint alternative \"$1\""
  [ "$_rc" -eq 0 ]
}

# check_section <composer key> <label>  ->  prints the number of entries seen
check_section() {
  _key=$1
  _label=$2
  _entries=$(jq -r --arg k "$_key" '.[$k] // {} | to_entries[] | "\(.key)\t\(.value)"' "$MANIFEST")
  _count=0
  if [ -n "$_entries" ]; then
    while IFS="$TAB" read -r _name _constraint; do
      [ -n "$_name" ] || continue
      _count=$((_count + 1))
      # Split the constraint into alternatives on |, comma and whitespace.
      for _alt in $(printf '%s' "$_constraint" | tr '|,' '  '); do
        if is_unstable "$_alt"; then
          finding "$MANIFEST" "$(line_of "\"$_name\"" "$MANIFEST")" \
            "$_label entry $_name has the unstable constraint \"$_constraint\" (alternative \"$_alt\"); only stable releases are allowed"
          break
        fi
      done
    done <<ENTRIES
$_entries
ENTRIES
  fi
  printf '%s' "$_count"
}

REQUIRE_N=$(check_section "require" "require")
REQUIRE_DEV_N=$(check_section "require-dev" "require-dev")
SCANNED=$((SCANNED + 1))

# -------------------------------------------------- stability-relaxing keys --
# Absent is fine for both: Composer defaults to `stable` / prefer-stable true.
if jq -e 'has("minimum-stability")' "$MANIFEST" >/dev/null 2>&1; then
  MS=$(jq -r '."minimum-stability"' "$MANIFEST")
  if [ "$MS" != "stable" ]; then
    finding "$MANIFEST" "$(line_of '"minimum-stability"' "$MANIFEST")" \
      "minimum-stability is \"$MS\"; it must be absent or \"stable\""
  fi
fi

if jq -e '."prefer-stable" == false' "$MANIFEST" >/dev/null 2>&1; then
  finding "$MANIFEST" "$(line_of '"prefer-stable"' "$MANIFEST")" \
    "prefer-stable is false; it must be absent or true"
fi

# ----------------------------------------------------------- composer.lock ---
# A `drupal-recipe` package does not version a lock file, so its absence is
# printed and is NOT a finding. The clause below runs unchanged the day one
# appears.
LOCK_PACKAGES=0
LOCK_STATE="absent"
if [ -f "$LOCK" ]; then
  [ -r "$LOCK" ] || fatal "$LOCK exists but is not readable"
  jq -e . "$LOCK" >/dev/null 2>&1 || fatal "$LOCK is not valid JSON"
  LOCK_STATE="present"
  SCOPE_LIST="$SCOPE_LIST, $LOCK"
  SCANNED=$((SCANNED + 1))
  LOCK_ENTRIES=$(jq -r '((.packages // []) + (.["packages-dev"] // []))[] | "\(.name)\t\(.version)"' "$LOCK")
  if [ -n "$LOCK_ENTRIES" ]; then
    while IFS="$TAB" read -r _lname _lver; do
      [ -n "$_lname" ] || continue
      LOCK_PACKAGES=$((LOCK_PACKAGES + 1))
      if is_unstable "$_lver"; then
        finding "$LOCK" "$(line_of "\"$_lname\"" "$LOCK")" \
          "locked package $_lname is at the unstable version \"$_lver\""
      fi
    done <<LOCKENTRIES
$LOCK_ENTRIES
LOCKENTRIES
  fi
fi

# ------------------------------------------------------------------ report --
# Before any count is printed: a grep that failed inside a subshell left its
# reason here, and `findings: 0` from a scan that did not run is a lie.
assert_grep_ok

printf '\nno-unstable-deps - unstable Composer constraints and stability settings\n\n'
printf 'scope:      %s\n' "$SCOPE_LIST"
printf 'scanned:    %s file(s)\n' "$SCANNED"
if [ "$LOCK_STATE" = "absent" ]; then
  printf 'lock:       composer.lock: absent (not a finding: a drupal-recipe package does not version a lock file)\n'
else
  printf 'lock:       composer.lock: present (%s locked packages checked)\n' "$LOCK_PACKAGES"
fi
printf 'keys:       require, require-dev, minimum-stability, prefer-stable\n'
printf 'require entries: %s\n' "$REQUIRE_N"
printf 'require-dev entries: %s\n' "$REQUIRE_DEV_N"
printf 'exclusions: none - the starter kit is out of scope, not excluded (see the header)\n'

# T-321(b): defaulted because zero means FAIL here - the safe direction of
# I-031. Do not copy the `:-0` to a guard where zero means PASS.
if [ "${SCANNED:-0}" -eq 0 ]; then
  printf 'findings:   n/a\n'
  printf '\nFAILURE: 0 files scanned - an empty scope proves nothing (I-007).\n'
  exit 1
fi

M=$(count_findings)
printf 'findings:   %s\n' "$M"

if [ "$M" -gt 0 ]; then
  printf '\n'
  cat "$FINDINGS_FILE"
  printf '\nFAILURE: %s unstable-dependency finding(s).\n' "$M"
  exit 1
fi

printf '\nOK: every declared constraint is stable.\n'
exit 0
