#!/bin/bash
# Frontend design-system lint guards for the MNW server.
#
# The server had no frontend lint at all, which is how 64 local rules
# re-specifying a generated primitive accumulated without anything noticing,
# and how two per-page sheets spent months referencing custom properties that
# had been deleted. Both classes are mechanical to detect, so they are.
#
# See docs/design-system.md. Exit 0 = clean, non-zero = violations (file:line).

set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
STATIC="$ROOT/static"
# The site's own sheets. The generated ones (geometry.css, layout.css,
# typography.css) are makeover's output and are never linted; they are the
# standard, not the code. They are still READ, because they are where the
# tokens the site's sheets spend are defined.
SITE_SHEETS="$STATIC/style.css $STATIC/wizard.css $STATIC/media-player.css"
GENERATED_SHEETS="$STATIC/geometry.css $STATIC/layout.css $STATIC/typography.css"

violations=0

report() {
    local rule="$1"; shift
    local msg="$1"; shift
    if [ -n "$*" ]; then
        echo
        echo "[$rule] $msg"
        echo "$*"
        violations=$((violations + 1))
    fi
}

# 1. No local rule may re-specify a property the generated sheet already sets
#    for the same primitive.
#
#    Unlayered CSS used to beat @layer makeover by construction, so the
#    generated sheet lost every contest it entered; the site said .card and
#    then told itself what a card is. style.css is in the `components` layer
#    now, which is still ahead of `makeover`, so the contest is stated rather
#    than accidental but the local rule still wins it. This gate is what makes
#    winning deliberate.
#
#    The primitives and their properties are read out of layout.css at lint
#    time rather than listed here, so regenerating makeover updates the rule.
#
#    A survivor carries `/* respec-ok: <reason naming what the generated sheet
#    cannot express> */` inside the rule body. "Different from what we had" is
#    the expected outcome of adopting a design system and is not a reason.
hits=$(python3 - "$GENERATED_SHEETS" "$SITE_SHEETS" <<'PY'
import re, sys

def rules(path):
    """(selector, body, line) for every rule with declarations, @media included."""
    raw = open(path).read()
    # Blank comments out rather than deleting them, so line numbers survive.
    src = re.sub(r'/\*.*?\*/', lambda m: re.sub(r'[^\n]', ' ', m.group()), raw, flags=re.S)
    out, stack, cur, i = [], [], '', 0
    while i < len(src):
        c = src[i]
        if c == '{':
            stack.append((cur.strip(), i)); cur = ''
        elif c == '}':
            if stack:
                sel, start = stack.pop()
                body = src[start + 1:i]
                if '{' not in body:
                    out.append((sel, body, raw[start + 1:i], src.count('\n', 0, start) + 1))
            cur = ''
        else:
            cur += c
        i += 1
    return out

def props(body):
    return {d.split(':')[0].strip() for d in body.split(';') if ':' in d and d.split(':')[0].strip().startswith(('-', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'))}

def keys(sel):
    """(class, state) per compound in the selector, so a rule is only ever
       compared against the generated rule for the SAME state. Unioning the
       states instead reports .card { color } against the generated
       .card:disabled, which is not a contest: they never both apply."""
    out = set()
    for compound in re.split(r'[\s>+~,]+', sel):
        m = re.match(r'\.([a-z][a-z0-9-]*)', compound)
        if not m:
            continue
        # Everything else in the compound is state: further classes as much as
        # pseudo-classes. Dropping the extra classes keys .tab.chosen as plain
        # .tab and lends the base primitive every property its modifiers set.
        state = ''.join(sorted(
            re.findall(r'(::?[a-z-]+(?:\([^)]*\))?|\[[^\]]*\])', compound)
            + re.findall(r'\.[a-z][a-z0-9-]*', compound)[1:]))
        out.add((m.group(1), state))
    return out

generated = {}
for path in sys.argv[1].split():
    for sel, body, _raw, _line in rules(path):
        for k in keys(sel):
            generated.setdefault(k, set()).update(props(body))

bad = []
for path in sys.argv[2].split():
    for sel, body, raw_body, line in rules(path):
        if 'respec-ok:' in raw_body:
            continue
        for cls, state in keys(sel):
            clash = generated.get((cls, state), set()) & props(body)
            if clash:
                short = ' '.join(sel.split())[:70]
                bad.append(f"  {path.split('/')[-1]}:{line}  {short}\n"
                           f"      re-specifies .{cls}{state}: {', '.join(sorted(clash))}")
for b in sorted(set(bad)):
    print(b)
PY
)
report "no-primitive-respec" \
    "A local rule re-specifies what the generated sheet already sets. Delete it and take the generated look, or add /* respec-ok: <reason> */ naming what the generated sheet cannot express." \
    "$hits"

# 2. Every var(--token) must resolve to a property something defines.
#
#    An undefined custom property is invalid at computed-value time, which
#    drops the WHOLE declaration rather than falling back — so this fails
#    silently and looks like a layout bug months later. 770f38c0 deleted the
#    brand-alias block and converted style.css but not the two per-page
#    sheets; both spent from then until 2026-08-10 dropping every colour they
#    declared. A var() with a fallback is fine and is skipped: those are the
#    properties JS sets at runtime.
hits=$(python3 - "$GENERATED_SHEETS $SITE_SHEETS" <<'PY'
import re, sys

paths = sys.argv[1].split()
defined = set()
for p in paths:
    defined |= set(re.findall(r'^\s*(--[a-z0-9-]+)\s*:', open(p).read(), re.M))

for p in paths:
    for n, line in enumerate(open(p), 1):
        for m in re.finditer(r'var\(\s*(--[a-z0-9-]+)\s*([,)])', line):
            if m.group(2) == ',':
                continue  # has a fallback: JS-set at runtime
            if m.group(1) not in defined:
                print(f"  {p.split('/')[-1]}:{n}  {m.group(1)} is used but never defined")
PY
)
report "no-undefined-token" \
    "var(--token) with no definition and no fallback. The whole declaration is dropped at computed-value time." \
    "$hits"

# 3. No raw colour literal outside the two blocks documented to hold them.
#
#    A literal picked against parchment and then applied to all 31 themes a
#    creator can select is the defect the bevel pair and then the elevation
#    intent were each introduced to undo, and it grew back both times. The
#    intent :root and the APP-LOCAL CONSTANTS block are where a literal is
#    allowed to live; everywhere else wants a token.
hits=$(python3 - "$SITE_SHEETS" <<'PY'
import re, sys

LITERAL = re.compile(r'(#[0-9a-fA-F]{3,8}\b|rgba?\(\s*[0-9])')
for p in sys.argv[1].split():
    src = open(p).read()
    src = re.sub(r'/\*.*?\*/', lambda m: re.sub(r'[^\n]', ' ', m.group()), src, flags=re.S)
    for n, line in enumerate(src.split('\n'), 1):
        if not LITERAL.search(line):
            continue
        decl = line.split(':')[0].strip()
        # A literal is allowed as the value of a custom property: that is what
        # a token IS. It is not allowed as the value of anything else.
        if decl.startswith('--'):
            continue
        print(f"  {p.split('/')[-1]}:{n}  {line.strip()[:80]}")
PY
)
report "no-colour-literal" \
    "Raw colour literal outside the intent :root and the APP-LOCAL CONSTANTS block. Use a token so it re-themes." \
    "$hits"

# 4. The site's sheets stay in the components layer.
#
#    An unlayered sheet outranks every named layer whatever the specificity,
#    so one un-wrapped file silently takes back every contest the layer order
#    was declared to settle. Cheap to check, so check it.
hits=""
for sheet in $SITE_SHEETS; do
    if ! grep -qE '^@layer components \{' "$sheet"; then
        hits="$hits  ${sheet##*/} does not open a components layer"$'\n'
    fi
done
report "layer-adoption" \
    "A site sheet is unlayered, so it beats every named layer regardless of specificity." \
    "$(echo "$hits" | sed '/^$/d')"

if [ $violations -eq 0 ]; then
    echo "frontend lint: clean"
    exit 0
else
    echo
    echo "frontend lint: $violations rule(s) failed"
    exit 1
fi
