#!/usr/bin/env bash
#
# no-colour-literals - task T-610.
#
# WHAT IT ASSERTS. Exactly one file in this theme may contain a colour literal:
# css/tokens.css. Every other stylesheet, and every template, reaches a colour
# through var(). This script is what turns that sentence from a rule people
# remember into a rule the pipeline enforces.
#
# WHY IT IS WORTH A GATE. The token file carries a declared contrast pair for
# every foreground-on-background combination the theme is allowed to render, and
# every one of those pairs has a computed WCAG ratio behind it. A colour typed
# straight into a rule has no pair, so no ratio, so nobody has checked it: it is
# a colour whose accessibility rests on how it looked to whoever typed it. The
# contrast checker cannot catch that either - it parses the token file, and a
# literal somewhere else is invisible to it by construction. This invariant is
# the other half of that pair of checks, and neither is redundant with the other.
#
# THE THREE-DIGIT FORM IS THE WHOLE REASON THE PATTERN IS WRITTEN THE WAY IT IS.
# Drupal core's stylelint configuration sets color-hex-length to `short`, so
# `#ffffff` is an ERROR that the fixer rewrites to `#fff`, and the stylelint job
# is blocking. A literal-hunting regular expression written for six digits would
# therefore be blind to every literal this repository is actually capable of
# containing - it would pass, permanently, having found nothing, and the pass
# would look identical to a real one (I-028). The dirty case below plants BOTH
# forms for exactly that reason, and fails if either escapes.
#
# WHAT COUNTS AS A LITERAL, in three families:
#   1. a hex literal          #fff  #ffffff  #ffff  #ffffffff
#   2. a colour function      rgb() rgba() hsl() hsla() hwb() lab() lch()
#                             oklab() oklch() color() color-mix() light-dark()
#   3. a named colour         white, black, red ... the full CSS list, matched
#                             only in a declaration's VALUE (after a colon)
#
# `transparent` and `currentColor` are NOT literals and are deliberately absent
# from the list: neither names a colour, both resolve to something the token
# system already decided.
#
# COMMENTS ARE NOT EXEMPT, and that is a decision rather than an oversight. This
# script does not parse CSS - it reads lines - so exempting comments would mean
# writing a comment stripper, and a stripper that is subtly wrong is a hole in
# the check rather than a bug in it. The stricter rule also happens to be the
# better one: a colour discussed in prose can be discussed by name
# (--agora-color-surface) instead of by value, and prose that quotes a hex value
# is prose that will still quote it after somebody changes the token.
#
# ⚠️ THE PRACTICAL CONSEQUENCE, WRITTEN OUT BECAUSE THE PARAGRAPH ABOVE STATES
# THE PRINCIPLE AND PEOPLE STILL LOSE AN HOUR TO IT. An ORDINARY ENGLISH
# SENTENCE containing a colour name turns this gate red. Not a declaration - a
# sentence. "a white box on the navy band", "near-black on navy", "the red
# state": each is a finding, reported as `file:line  a named CSS colour in a
# declaration value`, and that message names a declaration the line does not
# contain, which is the part that costs the hour. It happened twice in one
# afternoon while a stylesheet was being documented, and the fix each time was
# to rewrite the sentence, not the code.
#
# HOW TO WRITE THE SENTENCE INSTEAD: name the token, not the colour. "the page
# surface", "the inverse surface", "--agora-color-surface" all pass and all say
# more than the colour name did - which is the argument the paragraph above is
# making, arriving as a rule you cannot forget rather than one you can.
#
# WHY THE MESSAGE IS NOT BEING FIXED TO SAY "or in prose": because the check
# genuinely cannot tell the difference, and a message that hedged would be
# vaguer in the case that matters - a real literal in a real declaration.
#
# SCOPE INCLUDES TEMPLATES. A `style="color:#fff"` in a Twig file is a colour
# literal in every sense that matters; that it is not in a .css file is a
# property of where it was typed, not of what it is.
#
# Contract (shared by every invariant in tests/bin/):
#   scope:    the exact set of files inspected
#   scanned:  N files, and N must be > 0 or this script fails
#   findings: M, each printed as  path:line  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/no-colour-literals              scan this repository (gate mode)
#   tests/bin/no-colour-literals DIR          scan DIR instead
#   tests/bin/no-colour-literals --dirty-case build a fixture with four planted
#                                             literals, scan it, and require the
#                                             detector to have found every one

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

# ---------------------------------------------------------------- repo root --
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)

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>  - prints NO summary line (I-029)
  printf '\n%sFATAL:%s %s\n' "$C_BAD" "$C_OFF" "$1"
  exit 2
}

grep_fatal() { # <rc> <where>
  fatal "grep exited $1 while reading $2. A grep exit code of 2 or more is an error, never 'no match', and treating it as one would turn a broken read into a clean run."
}

# ----------------------------------------------------------- the token file --
# The ONE path allowed to contain literals, expressed relative to the scan root
# so that the fixture in --dirty-case exercises the exemption too.
TOKEN_FILE='css/tokens.css'

# ------------------------------------------------------------- the patterns --
# Hex. Three to eight digits covers #rgb, #rgba, #rrggbb and #rrggbbaa. The
# leading boundary is what keeps this from matching the tail of a longer word.
HEX_ERE='(^|[^[:alnum:]_-])#[0-9A-Fa-f]{3,8}([^[:alnum:]_-]|$)'

# Colour functions. The open parenthesis is required, which is what stops the
# property name `color:` and every `-color:` longhand from matching: those are
# followed by a colon, never by a parenthesis.
FUNC_ERE='(^|[^[:alnum:]_-])(rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix|light-dark)[[:space:]]*\('

# Named colours, matched only after a colon - that is, in the value of a
# declaration or of a style attribute, never in a selector or a class name. It
# relies on one declaration per line, which is what the formatter enforces
# anyway. The list below is the CSS named-colour set, quoted verbatim from the
# specification; `transparent` and `currentColor` are excluded on purpose.
#
# cspell:disable
NAMED_COLOURS='aliceblue
antiquewhite
aqua
aquamarine
azure
beige
bisque
black
blanchedalmond
blue
blueviolet
brown
burlywood
cadetblue
chartreuse
chocolate
coral
cornflowerblue
cornsilk
crimson
cyan
darkblue
darkcyan
darkgoldenrod
darkgray
darkgreen
darkgrey
darkkhaki
darkmagenta
darkolivegreen
darkorange
darkorchid
darkred
darksalmon
darkseagreen
darkslateblue
darkslategray
darkslategrey
darkturquoise
darkviolet
deeppink
deepskyblue
dimgray
dimgrey
dodgerblue
firebrick
floralwhite
forestgreen
fuchsia
gainsboro
ghostwhite
gold
goldenrod
gray
green
greenyellow
grey
honeydew
hotpink
indianred
indigo
ivory
khaki
lavender
lavenderblush
lawngreen
lemonchiffon
lightblue
lightcoral
lightcyan
lightgoldenrodyellow
lightgray
lightgreen
lightgrey
lightpink
lightsalmon
lightseagreen
lightskyblue
lightslategray
lightslategrey
lightsteelblue
lightyellow
lime
limegreen
linen
magenta
maroon
mediumaquamarine
mediumblue
mediumorchid
mediumpurple
mediumseagreen
mediumslateblue
mediumspringgreen
mediumturquoise
mediumvioletred
midnightblue
mintcream
mistyrose
moccasin
navajowhite
navy
oldlace
olive
olivedrab
orange
orangered
orchid
palegoldenrod
palegreen
paleturquoise
palevioletred
papayawhip
peachpuff
peru
pink
plum
powderblue
purple
rebeccapurple
red
rosybrown
royalblue
saddlebrown
salmon
sandybrown
seagreen
seashell
sienna
silver
skyblue
slateblue
slategray
slategrey
snow
springgreen
steelblue
tan
teal
thistle
tomato
turquoise
violet
wheat
white
whitesmoke
yellow
yellowgreen'
# cspell:enable

NAMED_ERE=":[^;{}]*(^|[^[:alnum:]_-])($(printf '%s' "$NAMED_COLOURS" | tr '\n' '|' | sed 's/|$//'))([^[:alnum:]_-]|$)"

# ------------------------------------------------------------------- scan() --
# Sets SCAN_FILES, SCAN_FINDINGS and prints one line per finding.
scan() { # <root>
  _root=$1
  SCAN_FILES=0
  SCAN_FINDINGS=0

  # Everything versioned or about to be: node_modules, vendor and the spell
  # checker's cache are third-party or generated and are not this theme's CSS.
  _list=$(find "$_root" \
    \( -name node_modules -o -name vendor -o -name .git -o -name .cspell-cache \) -prune -o \
    -type f \( -name '*.css' -o -name '*.twig' \) -print 2>/dev/null | sort)

  while IFS= read -r _f; do
    [ -n "$_f" ] || continue
    _rel=${_f#"$_root"/}
    if [ "$_rel" = "$TOKEN_FILE" ]; then
      note "$_rel: the token file - the one place a colour literal belongs, so it is not scanned"
      continue
    fi
    SCAN_FILES=$((SCAN_FILES + 1))

    for _kind in hex function named; do
      case $_kind in
        hex) _ere=$HEX_ERE; _why='a hex colour literal. Colours live in css/tokens.css and reach a rule through var(); a literal here has no declared contrast pair, so nothing has computed its ratio.' ;;
        function) _ere=$FUNC_ERE; _why='a colour function. Same rule as a hex literal: the value is not in the token file, so no pair and no computed ratio exist for it.' ;;
        named) _ere=$NAMED_ERE; _why='a named CSS colour in a declaration value. A name is a literal like any other - white is #fff with a friendlier spelling.' ;;
      esac
      _hits=$(LC_ALL=C grep -nE "$_ere" "$_f" 2>/dev/null)
      _rc=$?
      [ "$_rc" -ge 2 ] && grep_fatal "$_rc" "$_f"
      [ -n "$_hits" ] || continue
      while IFS= read -r _hit; do
        [ -n "$_hit" ] || continue
        SCAN_FINDINGS=$((SCAN_FINDINGS + 1))
        printf '  %s%s:%s%s  %s\n' "$C_BAD" "$_rel" "${_hit%%:*}" "$C_OFF" "$_why"
      done <<EOF
$_hits
EOF
    done
  done <<EOF
$_list
EOF
}

# -------------------------------------------------------------- dirty case --
# The detector is pointed at a tree built to be dirty, and is required to report
# every planted form. A check nobody has ever seen fail is a check nobody has
# ever seen work: this is the run that tells the difference.
dirty_case() {
  _fix=$(mktemp -d -t no-colour-literals.XXXXXX) || fatal "mktemp -d failed"
  trap 'rm -rf "$_fix"' EXIT INT TERM
  mkdir -p "$_fix/css" "$_fix/templates" || fatal "cannot build the fixture in $_fix"

  # The exemption itself, planted dirty: literals HERE must NOT be reported.
  printf ':root {\n  --x: #fff;\n  --y: #ffffff;\n  --z: rgb(0 0 0);\n}\n' > "$_fix/css/tokens.css"
  # One planted literal per file, so a miss names the form that escaped.
  printf '.a {\n  color: #fff;\n}\n' > "$_fix/css/dirty-short.css"
  printf '.b {\n  color: #ffffff;\n}\n' > "$_fix/css/dirty-long.css"
  printf '.c {\n  color: rgb(0 0 0);\n}\n' > "$_fix/css/dirty-function.css"
  printf '.d {\n  color: white;\n}\n' > "$_fix/css/dirty-named.css"
  # Clean by construction: colours only through var(), plus the words that must
  # never be mistaken for literals.
  printf '.e {\n  color: var(--agora-color-text);\n  border-color: transparent;\n}\n' > "$_fix/css/clean.css"
  printf '<div class="x">{{ content }}</div>\n' > "$_fix/templates/clean.html.twig"

  printf '=========================================================================================================\n'
  printf 'no-colour-literals - DIRTY CASE: four literals planted, one per file, plus two files that must stay clean\n'
  printf 'fixture: %s\n' "$_fix"
  printf '=========================================================================================================\n\n'

  # The scan's output is captured through a FILE and not through a command
  # substitution, and that is the whole reason this comment exists: a `$(scan
  # ...)` runs the function in a subshell, so SCAN_FILES and SCAN_FINDINGS are
  # set in a process that then exits and the caller reads neither. The first
  # version of this file did exactly that and died on an unbound variable AFTER
  # printing four correct verdicts - a dirty case that half worked, which is the
  # shape of failure this whole file exists to make impossible.
  _outfile=$(mktemp -t no-colour-literals-out.XXXXXX) || fatal "mktemp failed"
  scan "$_fix" > "$_outfile"
  _out=$(cat "$_outfile")
  rm -f "$_outfile"
  printf '%s\n' "$_out"

  _fail=0
  for _expect in dirty-short:hex dirty-long:hex dirty-function:function dirty-named:named; do
    _file=${_expect%%:*}
    _form=${_expect##*:}
    _n=$(printf '%s\n' "$_out" | grep -E "css/$_file\.css:" | wc -l | tr -d ' ')
    if [ "${_n:-0}" -ge 1 ]; then
      printf '  %-40s %s%s%s\n' "$_form form ($_file.css)" "$C_OK" "caught ($_n line(s))" "$C_OFF"
    else
      printf '  %-40s %s%s%s\n' "$_form form ($_file.css)" "$C_BAD" "ESCAPED - the detector is blind to it" "$C_OFF"
      _fail=$((_fail + 1))
    fi
  done

  for _clean in css/clean.css templates/clean.html.twig css/tokens.css; do
    _n=$(printf '%s\n' "$_out" | grep -E "^  .*$_clean:[0-9]" | wc -l | tr -d ' ')
    if [ "${_n:-0}" -eq 0 ]; then
      printf '  %-40s %s%s%s\n' "$_clean" "$C_OK" "correctly silent" "$C_OFF"
    else
      printf '  %-40s %s%s%s\n' "$_clean" "$C_BAD" "FALSE POSITIVE ($_n)" "$C_OFF"
      _fail=$((_fail + 1))
    fi
  done

  printf '\nfixture scanned:  %s file(s) (the token file is exempt and is not counted)\n' "$SCAN_FILES"
  printf 'fixture findings: %s - expected 4\n' "$SCAN_FINDINGS"
  printf 'gate-mode exit code over this fixture would be: 1\n'
  if [ "$SCAN_FINDINGS" -ne 4 ]; then
    # The colour codes are passed as separate arguments and never glued to the
    # first word of the message: a '%s' conversion followed immediately by a word
    # reads to a spell checker as one token, and the blocking cspell job reports
    # it. The project word list already carries two casualties of that habit;
    # this is a third that did not need to become one.
    printf '%s%s%s - the detector is over- or under-reporting.\n' "$C_BAD" 'the finding count is not 4' "$C_OFF"
    _fail=$((_fail + 1))
  fi

  if [ "$_fail" -ne 0 ]; then
    printf '\n%sDIRTY CASE FAILED:%s %d check(s) went wrong. The detector cannot be trusted to fail, so its passes mean nothing.\n' "$C_BAD" "$C_OFF" "$_fail"
    exit 1
  fi
  printf '\n%sDIRTY CASE PASSED:%s every planted literal was reported and every clean file stayed silent.\n' "$C_OK" "$C_OFF"
  exit 0
}

# ------------------------------------------------------------------- main --
case "${1:-}" in
  --dirty-case) dirty_case ;;
  '') ROOT=$REPO_ROOT ;;
  -*) fatal "unknown option ${1}. Usage: no-colour-literals [DIR | --dirty-case]" ;;
  *) ROOT=$(CDPATH= cd -- "$1" 2>/dev/null && pwd) || fatal "not a directory: $1" ;;
esac

printf '=========================================================================================================\n'
printf 'no-colour-literals - every colour outside %s must come through var() (T-610)\n' "$TOKEN_FILE"
printf 'root: %s\n' "$ROOT"
printf '=========================================================================================================\n\n'
printf 'scope:    *.css and *.twig under the root, excluding %s\n' "$TOKEN_FILE"

scan "$ROOT"

printf '\nscanned:  %s file(s)\n' "$SCAN_FILES"
printf 'findings: %s\n' "$SCAN_FINDINGS"

if [ "${SCAN_FILES:-0}" -eq 0 ]; then
  fatal "0 files scanned. This repository ships stylesheets, so a scan that opened nothing has broken rather than found nothing - and 'no literals in no files' is the degenerate pass this project keeps having to un-learn (I-028)."
fi

if [ "$SCAN_FINDINGS" -ne 0 ]; then
  printf '\n%sFAILED:%s %s colour literal(s) outside %s.\n' "$C_BAD" "$C_OFF" "$SCAN_FINDINGS" "$TOKEN_FILE"
  printf 'Add the colour to %s with its contrast pairs and reference it with var(), or\n' "$TOKEN_FILE"
  printf 'use a token that already exists. Do not add an exception to this script.\n'
  exit 1
fi

printf '\n%sOK:%s every colour outside %s comes through var().\n' "$C_OK" "$C_OFF" "$TOKEN_FILE"
exit 0
