#!/usr/bin/env bash
# build.sh · In the Gesture SSG
# Lives in the project root on the server and rebuilds the site in place:
#   0. validates every gestures/YYYY-MM.xml against xml/gestures.rng
#      (RELAX NG, via jing or xmllint) and halts on any error
#   1. merges gestures/YYYY-MM.xml, every month there is, into a single
#      chronological xml/gestures.xml (xsl/assemble.xsl, which also halts
#      on unreadable times and duplicate slugs)
#   2. warns, without halting, about any image, video, or figure whose
#      file is missing from captures/, and makes any missing thumbnail:
#      thumbnails/<same filename>, 180 px on the long side (ImageMagick)
#   3. bursts xml/gestures.xml into g/*.html and html/log.html
#   4. builds html/works.html and html/works/{work-id}.html
#   5. writes xml/rss.xml, a feed of the ten most recent gestures
#   6. verifies that every generated page carries its required OG properties
#
# Uses the first XSLT 3.0 processor it finds:
#   1. xslt3      SaxonJS for Node         npm install -g xslt3
#   2. Saxon-HE   for Java                 $SAXON_JAR, or else the newest
#                                          /opt/saxon/saxon-he-<version>.jar
#   3. saxonche   SaxonC-HE for Python     pip install saxonche

set -Eeuo pipefail
shopt -s nullglob
cd "$(dirname "$0")"

# ---------------------------------------------------------------- output
# Green: built cleanly. Yellow: built, with warnings. Red: halted.
# Color only on a terminal, so cron logs stay plain text; NO_COLOR=1
# turns it off anywhere.
if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then
    RED=$'\e[31m'; YELLOW=$'\e[33m'; GREEN=$'\e[32m'; BOLD=$'\e[1m'; RESET=$'\e[0m'
else
    RED=""; YELLOW=""; GREEN=""; BOLD=""; RESET=""
fi
warnings=0

fatal () { echo "${RED}${BOLD}FATAL:${RESET}${RED} $*${RESET}" >&2; exit 1; }
warn ()  { echo "${YELLOW}WARNING: $*${RESET}" >&2; warnings=$((warnings + 1)); }

# Any command that fails without its own message (a stylesheet error,
# say) still ends the build in red.
trap 'echo "${RED}${BOLD}FATAL:${RESET}${RED} build halted at line $LINENO — see the errors above${RESET}" >&2' ERR

# The script cd's to its own location, so it must live in the project root.
for x in common assemble gestures works rss; do
    [ -f "xsl/$x.xsl" ] || fatal "xsl/$x.xsl not found — build.sh belongs in the project root, beside xsl/"
done
[ -d gestures ]      || fatal "no gestures/ directory beside build.sh"
for x in works nav; do
    [ -f "xml/$x.xml" ] || fatal "xml/$x.xml not found"
done
[ -f xml/gestures.rng ] || fatal "xml/gestures.rng not found"

mkdir -p g html html/works xml thumbnails

# Scratch files, removed however the build ends
trap 'rm -f .months.xml .media.tsv .principal.tmp xml/.gestures.xml.new thumbnails/.tmp-*' EXIT

# ---------------------------------------------------------------- processor
XSLT=""
if command -v xslt3 >/dev/null 2>&1; then
    XSLT=xslt3; echo "Using SaxonJS (xslt3)"
else
    if [ -n "${SAXON_JAR:-}" ] && [ ! -f "$SAXON_JAR" ]; then
        warn "SAXON_JAR is set to $SAXON_JAR, which does not exist; looking elsewhere"
        SAXON_JAR=""
    fi
    if [ -z "${SAXON_JAR:-}" ]; then
        jars=(/opt/saxon/saxon-he-[0-9]*.jar)
        if [ ${#jars[@]} -gt 0 ]; then
            SAXON_JAR=$(printf '%s\n' "${jars[@]}" | sort -V | tail -n 1)
        fi
    fi
    if [ -n "${SAXON_JAR:-}" ] && command -v java >/dev/null 2>&1; then
        XSLT=saxonj; echo "Using Saxon-HE for Java ($SAXON_JAR)"
    elif python3 -c 'import saxonche' >/dev/null 2>&1; then
        XSLT=saxonche; echo "Using SaxonC-HE (saxonche)"
    fi
fi
[ -n "$XSLT" ] || fatal "no XSLT 3.0 processor found (xslt3, Saxon-HE for Java, or saxonche)"

# Every stylesheet writes through xsl:result-document with hrefs relative
# to the project root; the principal output is empty and discarded.
run_pair () {  # $1 = xsl, $2 = source
    case "$XSLT" in
        xslt3)
            xslt3 "-xsl:$1" "-s:$2" "-o:.principal.tmp" ;;
        saxonj)
            java -jar "$SAXON_JAR" "-xsl:$1" "-s:$2" "-o:.principal.tmp" ;;
        saxonche)
            python3 - "$1" "$2" <<'EOF'
import os, sys
from saxonche import PySaxonProcessor
xsl, src = sys.argv[1], sys.argv[2]
root = os.getcwd()
try:
    with PySaxonProcessor(license=False) as proc:
        proc.set_cwd(root)
        xp = proc.new_xslt30_processor()
        ex = xp.compile_stylesheet(stylesheet_file=os.path.join(root, xsl))
        if ex is None:
            sys.exit(f"{xsl}: {xp.error_message}")
        ex.set_base_output_uri("file://" + root + "/")
        ex.apply_templates_returning_file(
            source_file=os.path.join(root, src),
            output_file=os.path.join(root, ".principal.tmp"))
        if getattr(ex, "exception_occurred", False):
            sys.exit(f"{xsl}: {ex.error_message}")
except Exception as e:
    sys.exit(f"{xsl}: {e}")
EOF
            ;;
    esac
    rm -f .principal.tmp
}

# ---------------------------------------------------------------- 1. merge
# Month files are gestures/YYYY-MM.xml. New months are picked up as they
# appear; anything else in gestures/ is named and skipped.
months=()
for f in gestures/*.xml; do
    if [[ ${f#gestures/} =~ ^[0-9]{4}-(0[1-9]|1[0-2])\.xml$ ]]; then
        months+=("$f")
    else
        warn "skipping $f — month files are named YYYY-MM.xml"
    fi
done
[ ${#months[@]} -gt 0 ] || fatal "no month files (YYYY-MM.xml) in gestures/"

# ---------------------------------------------------------------- 0. validate
# Structure, datatypes, and full xsd:dateTime times with offsets.
# Every month is checked and every error reported before halting.
if command -v jing >/dev/null 2>&1; then
    echo "Validating with jing"
    jing xml/gestures.rng "${months[@]}" \
        || fatal "gestures failed RELAX NG validation against xml/gestures.rng"
elif command -v xmllint >/dev/null 2>&1; then
    echo "Validating with xmllint"
    invalid=0
    for f in "${months[@]}"; do
        if ! out=$(xmllint --noout --relaxng xml/gestures.rng "$f" 2>&1); then
            printf '%s\n' "$out" | grep -v ' validates$' >&2
            invalid=1
        fi
    done
    [ "$invalid" -eq 0 ] \
        || fatal "gestures failed RELAX NG validation against xml/gestures.rng"
else
    fatal "no RELAX NG validator found (jing, or xmllint from libxml2)"
fi

{
    echo '<?xml version="1.0" encoding="utf-8"?>'
    echo '<months>'
    for f in "${months[@]}"; do echo "  <month href=\"$f\"/>"; done
    echo '</months>'
} > .months.xml

run_pair xsl/assemble.xsl .months.xml
[ -s xml/.gestures.xml.new ] && [ -e .media.tsv ] \
    || fatal "xsl/assemble.xsl produced no output — see the errors above"
mv xml/.gestures.xml.new xml/gestures.xml

# ---------------------------------------------------------------- 2. captures
# Missing files are reported, not fatal: the pages still build, with
# broken images until the capture is uploaded.
#
# Every image capture gets a thumbnail of the same name in thumbnails/,
# 180 px on its long side, for html/log.html. One is made when it is
# missing, or remade when the capture is newer (a replaced photo).
# -auto-orient applies the camera's rotation flag before it is stripped;
# [0] takes the first frame of a GIF. Each thumbnail is written to a
# temporary name first, so a failed conversion leaves nothing half-made.
IM=""
if command -v magick >/dev/null 2>&1; then IM=magick
elif command -v convert >/dev/null 2>&1; then IM=convert
fi

make_thumb () {  # $1 = filename in captures/
    local tmp="thumbnails/.tmp-$1"
    "$IM" "captures/$1[0]" -auto-orient -thumbnail '180x180>' \
          -strip -quality 85 "$tmp" 2>/dev/null \
        && mv "$tmp" "thumbnails/$1"
}

# Can the web server read it? The build runs as you, so it can use files
# the web server can't, and those would 403 on the site. Two causes:
#   - permissions: the file isn't readable by everyone (o+r)
#   - SELinux: the file kept the label of wherever it was made, as mv
#     does, instead of taking its folder's label as a new file would
SELINUX=0
if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then SELINUX=1; fi
unservable=0

declare -A dir_checked=()

check_served () {  # $1 = path to a file the site serves
    local f=$1 dir ftype dtype
    dir=$(dirname "$f")
    # The folder must be searchable by everyone (o+x), or every file in
    # it 403s however its own permissions read. Checked once per folder.
    if [ -z "${dir_checked[$dir]:-}" ]; then
        dir_checked[$dir]=1
        if (( (8#$(stat -c %a "$dir") & 1) == 0 )); then
            warn "web server can't open anything in $dir/ (folder not searchable by everyone) — fix: chmod o+x $dir"
            unservable=$((unservable + 1))
        fi
    fi
    if (( (8#$(stat -c %a "$f") & 4) == 0 )); then
        warn "web server can't read $f (not readable by everyone) — fix: chmod o+r $f"
        unservable=$((unservable + 1))
    fi
    if [ "$SELINUX" -eq 1 ]; then
        ftype=$(stat -c %C "$f" | cut -d: -f3)
        dtype=$(stat -c %C "$dir" | cut -d: -f3)
        if [ "$ftype" != "$dtype" ]; then
            warn "web server may not be allowed to read $f (SELinux type $ftype, its folder's is $dtype) — fix: chcon --reference=$dir $f"
            unservable=$((unservable + 1))
        fi
    fi
}

missing=0
thumbs=0
while IFS=$'\t' read -r when kind file; do
    if [ ! -f "captures/$file" ]; then
        warn "missing capture: captures/$file ($kind in gesture $when)"
        missing=$((missing + 1))
        continue
    fi
    check_served "captures/$file"
    shopt -s nocasematch
    [[ $file =~ \.(jpe?g|png|webp|gif)$ ]] && is_image=1 || is_image=0
    shopt -u nocasematch
    [ "$is_image" -eq 1 ] || continue
    if [ ! -f "thumbnails/$file" ] || [ "captures/$file" -nt "thumbnails/$file" ]; then
        [ -n "$IM" ] || fatal "thumbnails/$file needs making, but ImageMagick (magick or convert) isn't installed"
        if make_thumb "$file"; then
            echo "Made thumbnails/$file"
            thumbs=$((thumbs + 1))
        else
            rm -f "thumbnails/.tmp-$file"
            warn "could not make a thumbnail of captures/$file — is it a readable image?"
        fi
    fi
    [ -f "thumbnails/$file" ] && check_served "thumbnails/$file"
done < .media.tsv

# Fixed assets named in xsl/common.xsl
for f in assets/in-the-gesture.png assets/favicon.png; do
    if [ ! -f "$f" ]; then
        warn "missing site asset: $f"
        missing=$((missing + 1))
    else
        check_served "$f"
    fi
done

# ---------------------------------------------------------------- 3–5. pages
run_pair xsl/gestures.xsl xml/gestures.xml
run_pair xsl/works.xsl    xml/works.xml
run_pair xsl/rss.xsl      xml/gestures.xml

[ -s xml/rss.xml ] || fatal "xsl/rss.xsl did not write xml/rss.xml"

# ---------------------------------------------------------------- 6. verify
# Every generated page must carry the four required OG properties.
# A failure here means an ig:page call site isn't passing its
# $gesture / $work / $path parameter — or a stale page is lingering.
og_missing=0
for f in g/*.html html/log.html html/works.html html/works/*.html; do
    [ -e "$f" ] || continue
    for prop in og:title og:type og:url og:image; do
        if ! grep -q "property=\"$prop\"" "$f"; then
            echo "OG MISSING: $f lacks $prop" >&2
            og_missing=1
        fi
    done
done
if [ "$og_missing" -ne 0 ]; then
    fatal "pages missing required Open Graph properties — they are already written in place, so fix and rebuild"
fi

# ---------------------------------------------------------------- summary
pages=(g/*.html)
works=(html/works/*.html)
items=$(grep -c '<item>' xml/rss.xml || true)
if [ "$warnings" -eq 0 ]; then C=$GREEN; else C=$YELLOW; fi
echo "${C}Built ${#pages[@]} gesture pages → g/"
echo "Built html/log.html, html/works.html, and ${#works[@]} work pages"
echo "Built xml/rss.xml with $items items"
echo "Made $thumbs new thumbnail(s) → thumbnails/"
echo "Verified Open Graph metadata on all generated pages"
if [ "$missing" -gt 0 ]; then
    echo "$missing missing file(s) — see the warnings above"
fi
if [ "$unservable" -gt 0 ]; then
    echo "$unservable file(s) the web server may not be able to read — see the warnings above"
fi
if [ "$warnings" -eq 0 ]; then
    echo "${BOLD}Build succeeded${RESET}"
else
    echo "${BOLD}Build succeeded with $warnings warning(s)${RESET}"
fi
