#!/usr/bin/env bash
#
# component-metadata - task T-1403.
#
# EVERY COMPONENT THIS THEME SHIPS MUST BE ONE DRUPAL CANVAS WILL ACTUALLY
# OFFER, and the reason this needs a gate of its own is that Canvas refuses a
# component SILENTLY. Its ComponentMetadataRequirementsChecker builds a list of
# objections and throws; the manager that discovers components catches that,
# stores the reason in a repository nobody reads, and does not create the
# component. There is no error, no warning and no log entry. The component is
# valid YAML on disk and absent from the palette, so:
#
#   * the theme still installs
#   * every stylesheet, template and test still passes
#   * the accessibility gate still scans its fixtures and finds nothing wrong
#   * and a site owner can no longer place a heading on a page
#
# That combination - a real capability lost, with every existing gate green - is
# what this file exists for. "The YAML is on disk" is exactly the evidence that
# proves nothing here.
#
# WHAT IS CHECKED, and each is a message the checker really builds rather than a
# rule inferred from documentation:
#
#   1. every prop has `title`                "Prop %s must have title"
#   2. every slot has `title`                "Slot %s must have title"
#   3. every REQUIRED prop has `examples`    "Prop %s is required, but does not
#                                             have example value"
#   4. no enum carries an empty value        "Prop %s has an empty enum value."
#   5. the category is not "Elements"        reserved by Canvas
#   6. every name in `required` is really    NOT one of Canvas's messages, and
#      declared in `properties`               the reason it is here anyway: a
#                                             typo there makes the prop silently
#                                             optional, so rule 3 stops applying
#                                             to it and nothing anywhere says so
#   7. a *.twig sits beside each file        without it SDC discovers nothing
#
# WHAT IS DELIBERATELY NOT CHECKED: whether Canvas can find a field type for
# each prop shape. That is a real requirement and a real way to lose a
# component, and it cannot be answered by reading a file - the checker asks a
# live service, and the answer depends on which modules are installed. Naming it
# here is better than a silent gap: the rig is where that one is observed, by
# looking at the palette.
#
# ON THE PARSER. tests/bin/component-metadata.py reads the restricted YAML
# subset these files are written in, because PyYAML is not in the standard
# library and nothing guarantees it on the runner. python3 itself is already a
# hard requirement of tests/bin/no-secrets, so it is known to be there.
#
# A HAND-WRITTEN PARSER THAT READS NOTHING PRINTS "0 findings" EXACTLY LIKE A
# CLEAN TREE (I-028), so this script does two things about it. It prints its
# denominators - components, props and slots - rather than only a verdict; and
# before it looks at the real files it runs the walker over a FIXTURE whose
# findings are known, including one component that is correct and one that
# breaks all six readable rules. If the fixture does not produce exactly what it
# should, this exits FATAL and never reports on the real tree at all.
#
# The fixture's most useful case is not any of the six. It is a folded block
# scalar containing a line that reads `title: not a real key`, on a prop that
# has no title of its own. A scanner that counted indentation and nothing else
# would read that prose as structure and call the prop titled. That is the
# difference between a parser and a regular expression, and it is why the
# fixture is checked for the RIGHT findings rather than for a non-zero count.
#
# Contract (shared by every invariant in tests/bin/):
#   scope:    the exact set of files inspected
#   scanned:  N components, and N must be > 0 or this script fails
#   findings: M, each printed as  path  field  reason
#   exit 0 if and only if M == 0
#
# 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`.
#
# Usage: tests/bin/component-metadata   (run from anywhere; it cd's to the root)

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

SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
cd "$REPO_ROOT" || { echo "FATAL: cannot cd to repo root $REPO_ROOT"; exit 1; }

WALKER="$SCRIPT_DIR/component-metadata.py"
COMPONENT_DIR=components

C_OK=""; C_BAD=""; C_OFF=""; C_DIM=""
if [ -t 1 ]; then
  C_OK=$'\033[32m'; C_BAD=$'\033[31m'; C_OFF=$'\033[0m'; C_DIM=$'\033[2m'
fi

note() { printf '  %s%s%s\n' "$C_DIM" "$1" "$C_OFF"; }

fatal() { # <reason>
  printf '\n%sFATAL:%s %s\n' "$C_BAD" "$C_OFF" "$1"
  printf 'This run has NO result, which is not a clean result (I-029).\n'
  exit 1
}

printf '=========================================================================================================\n'
printf 'component-metadata - every component is one Canvas will actually offer (T-1403)\n'
printf 'root: %s\n' "$REPO_ROOT"
printf '=========================================================================================================\n'

# ---------------------------------------------------------------- preflight --
# I-026: EXERCISE the tool, do not merely locate it. On a Windows host `python3`
# can resolve to a Microsoft Store stub that satisfies `command -v` and then
# fails at first real use.
command -v python3 >/dev/null 2>&1 || fatal 'python3 is not available - this invariant cannot be evaluated, and a check that cannot run is a FAIL rather than a skip.'
python3 -c 'pass' >/dev/null 2>&1 || fatal 'python3 resolves but does not run - on this host that is the Microsoft Store stub (I-026).'
[ -r "$WALKER" ] || fatal "$WALKER is missing - the invariant is this script plus that walker; one without the other is decoration."

# ------------------------------------------------------------- the fixture --
# Falsification, run BEFORE the real tree. A walker that returned nothing would
# otherwise report a clean repository, which is the shape of green this whole
# file exists to refuse.
FIXTURE=$(mktemp -d -t component-metadata.XXXXXX) || fatal 'mktemp -d failed.'
trap 'rm -rf "$FIXTURE"' EXIT INT TERM

mkdir -p "$FIXTURE/components/good" "$FIXTURE/components/bad"
: > "$FIXTURE/components/good/good.twig"
: > "$FIXTURE/components/bad/bad.twig"

cat > "$FIXTURE/components/good/good.component.yml" <<'GOOD'
name: Good
group: Base
props:
  type: object
  required:
    - alpha
  properties:
    alpha:
      type: string
      title: Alpha
      examples:
        - a
    beta:
      type: string
      title: Beta
slots:
  body:
    title: Body
GOOD

cat > "$FIXTURE/components/bad/bad.component.yml" <<'BAD'
name: Bad
group: Elements
props:
  type: object
  required:
    - gamma
    - ghost
  properties:
    gamma:
      type: string
      description: >-
        A folded block whose second line reads
        title: not a real key
    delta:
      type: string
      title: Delta
      enum:
        - ""
        - x
slots:
  body:
    description: No title here
BAD

FIX_OUT=$(python3 "$WALKER" "$FIXTURE" 2>&1)
FIX_RC=$?
FIX_SUMMARY=$(printf '%s\n' "$FIX_OUT" | grep -E '^SUMMARY ' | tail -1)
FIX_EXPECT='SUMMARY components=2 props=4 slots=2 findings=6'

printf '\nfixture (the walker is falsified before it is believed)\n'
note "components: one correct, one breaking all six readable rules"
note "the trap: a folded description containing a line that reads 'title: ...'"
note "obtained: ${FIX_SUMMARY:-<no summary line>}"
note "expected: $FIX_EXPECT"

if [ "$FIX_SUMMARY" != "$FIX_EXPECT" ]; then
  printf '%s\n' "$FIX_OUT"
  fatal "the walker did not produce the known result on the fixture. Either it has stopped parsing, or the fixture was changed without the expectation - and until that is settled nothing it says about the real files means anything."
fi
if [ "$FIX_RC" -eq 0 ]; then
  fatal 'the walker exited 0 on a fixture with six findings, so its exit code carries no information.'
fi

# Each of the six is asserted BY NAME. A count of six could be six copies of one
# rule, which would leave five rules unproven while the total looked right.
FIX_MISSING=''
for _needle in \
  'group  uses the reserved' \
  'required/ghost  named in `required`' \
  'props/gamma  no `title`' \
  'props/gamma  required but has no `examples`' \
  'props/delta  has an empty `enum` value' \
  'slots/body  no `title`'
do
  printf '%s\n' "$FIX_OUT" | grep -qF -- "$_needle"
  _rc=$?
  [ "$_rc" -ge 2 ] && fatal "grep exited $_rc while matching the fixture output (I-027)."
  [ "$_rc" -eq 0 ] || FIX_MISSING="$FIX_MISSING
    $_needle"
done
if [ -n "$FIX_MISSING" ]; then
  printf '%s\n' "$FIX_OUT"
  fatal "the fixture produced six findings but not the six expected ones. Missing:$FIX_MISSING"
fi
note "all six rules fired by name, and the exit code was non-zero"

# ------------------------------------------------------------- the real run --
printf '\nscope:    %s/*/*.component.yml\n' "$COMPONENT_DIR"
[ -d "$COMPONENT_DIR" ] || fatal "$COMPONENT_DIR/ does not exist. This theme ships single-directory components; a scan with nowhere to look is not a clean scan."

OUT=$(python3 "$WALKER" "$REPO_ROOT" 2>&1)
RC=$?
SUMMARY=$(printf '%s\n' "$OUT" | grep -E '^SUMMARY ' | tail -1)
[ -n "$SUMMARY" ] || { printf '%s\n' "$OUT"; fatal 'the walker printed no summary line, so this run has no denominators and no verdict.'; }

COMPONENTS=$(printf '%s' "$SUMMARY" | sed -n 's/.*components=\([0-9]*\).*/\1/p')
PROPS=$(printf '%s' "$SUMMARY" | sed -n 's/.*props=\([0-9]*\).*/\1/p')
SLOTS=$(printf '%s' "$SUMMARY" | sed -n 's/.*slots=\([0-9]*\).*/\1/p')
FINDINGS=$(printf '%s' "$SUMMARY" | sed -n 's/.*findings=\([0-9]*\).*/\1/p')

printf '\nper component\n'
printf '%s\n' "$OUT" | grep -E '^DETAIL ' | sed 's/^DETAIL /  /'

if [ "${FINDINGS:-0}" -gt 0 ] 2>/dev/null; then
  printf '\nfindings\n'
  printf '%s\n' "$OUT" | grep -E '^FINDING ' | sed "s/^FINDING /  ${C_BAD}/;s/$/${C_OFF}/"
fi

printf '\nscanned:  %s components, %s props, %s slots\n' \
  "${COMPONENTS:-0}" "${PROPS:-0}" "${SLOTS:-0}"
printf 'findings: %s\n' "${FINDINGS:-0}"

# THE THREE DENOMINATORS ARE GUARDED SEPARATELY, and that is not one question
# asked three times. The component count comes from a directory walk, so it can
# be positive while the parser reads nothing; the prop and slot counts come from
# the parser, so they can only be positive if the files were opened and walked.
# "4 components, 0 props checked, 0 findings" is the exact shape of green this
# invariant exists to refuse (I-028, I-045).
[ "${COMPONENTS:-0}" -gt 0 ] 2>/dev/null || fatal '0 components scanned. A theme that ships no component would have nothing for a site owner to place, so an empty scan here is a broken scan, not a clean one.'
[ "${PROPS:-0}" -gt 0 ] 2>/dev/null || fatal '0 props scanned across a non-zero number of components - the parser opened the files and read nothing out of them.'
[ "${SLOTS:-0}" -gt 0 ] 2>/dev/null || fatal '0 slots scanned. The layout components declare slots; a zero here means the slots half of this check is asserting nothing, which is how the slot rule would stop being enforced without anyone noticing.'

printf '\n=========================================================================================================\n'
if [ "${FINDINGS:-0}" -eq 0 ] && [ "$RC" -eq 0 ]; then
  printf '%s%s components, %s props, %s slots checked, %s findings%s\n' \
    "$C_OK" "$COMPONENTS" "$PROPS" "$SLOTS" "$FINDINGS" "$C_OFF"
  printf '=========================================================================================================\n'
  exit 0
fi
printf '%s%s components, %s props, %s slots checked, %s findings%s\n' \
  "$C_BAD" "$COMPONENTS" "$PROPS" "$SLOTS" "$FINDINGS" "$C_OFF"
printf '=========================================================================================================\n'
exit 1
