#!/bin/bash
#
# klammerset_test.sh — Regression tests for the @@@klammerset system command.
#
# @@@klammerset declares a Klammerset: a named, logically related group of
# klammer definitions.  The declaration is operative — processing it reads
# the :requires files and then the :files, in list order, at the point of the
# declaration; relative names resolve against the declaring file's directory,
# never the cwd.  A repeated declaration of an already-registered symbol is
# skipped (loaded once), which is what makes :requires idempotent.  The
# klammers themselves live in the Machine's flat Klammer_registry; the
# Klammerset holds metadata and the file list only.
#
# Engine tier: no SKS.  Fixtures live in tst/klammerset/ and define their own
# target ("fix") inline.
#
# Usage: ./klammerset_test.sh
# Exit code: 0 if all tests pass, 1 otherwise.

PASS=0
FAIL=0
KTEXT=ktext
K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}
FIX=$K/tst/klammerset

# The fixtures are laid out as <symbol>/<symbol>.k under $FIX, because since
# 2026-08-14 the command line takes klammerset SYMBOLS only -- a set that
# cannot be named by one cannot be loaded.  Putting $FIX on the search path is
# how the suite makes its own fixtures nameable, and it also exercises stage 2
# (KLAMMERTEXT_KLAMMERSETS) for both the command line and ":requires".
# Tests that need a different path set it themselves on the command.
export KLAMMERTEXT_KLAMMERSETS=$FIX

red=$'\033[31m'
green=$'\033[32m'
bold=$'\033[1m'
reset=$'\033[0m'

# strip leading/trailing blank lines and surrounding whitespace
trim() { awk '{ sub(/[ \t\r]+$/, "") } { line[NR]=$0 } END { f=1; while (f<=NR && line[f]=="") f++; l=NR; while (l>=1 && line[l]=="") l--; for (i=f;i<=l;i++) print line[i] }'; }

# check_eq TEST_NAME EXPECTED KTEXT_ARGS...
#   Runs ktext, expects exit status 0, and compares trimmed stdout to EXPECTED.
check_eq() {
    local test_name="$1"
    local expected="$2"
    shift 2

    local output status
    output=$("$KTEXT" "$@" 2>/tmp/klammerset_test_err.$$)
    status=$?
    output=$(printf '%s' "$output" | trim)

    if [ $status -ne 0 ]; then
        echo "${red}FAIL${reset} $test_name — ktext exited $status"
        echo "       stderr: $(head -3 /tmp/klammerset_test_err.$$)"
        FAIL=$((FAIL + 1))
        return
    fi
    if [ "$output" = "$expected" ]; then
        echo "${green}PASS${reset} $test_name"
        PASS=$((PASS + 1))
    else
        echo "${red}FAIL${reset} $test_name"
        echo "       expected: [$expected]"
        echo "       got:      [$output]"
        FAIL=$((FAIL + 1))
    fi
}

# check_contains TEST_NAME SUBSTRING KTEXT_ARGS...
#   Runs ktext, expects exit status 0, and checks that the combined output
#   contains SUBSTRING.
check_contains() {
    local test_name="$1"
    local needle="$2"
    shift 2

    local output status
    output=$("$KTEXT" "$@" 2>&1)
    status=$?

    if [ $status -ne 0 ]; then
        echo "${red}FAIL${reset} $test_name — ktext exited $status"
        echo "       output: $(echo "$output" | head -3)"
        FAIL=$((FAIL + 1))
        return
    fi
    if printf '%s' "$output" | grep -qF "$needle"; then
        echo "${green}PASS${reset} $test_name"
        PASS=$((PASS + 1))
    else
        echo "${red}FAIL${reset} $test_name"
        echo "       expected output to contain: [$needle]"
        echo "       got: $(echo "$output" | head -5)"
        FAIL=$((FAIL + 1))
    fi
}

# check_fails TEST_NAME SUBSTRING KTEXT_ARGS...
#   Runs ktext, expects a NONZERO exit status, and checks that the combined
#   output contains SUBSTRING.
check_fails() {
    local test_name="$1"
    local needle="$2"
    shift 2

    local output status
    output=$("$KTEXT" "$@" 2>&1)
    status=$?

    if [ $status -eq 0 ]; then
        echo "${red}FAIL${reset} $test_name — expected an error, ktext exited 0"
        FAIL=$((FAIL + 1))
        return
    fi
    if printf '%s' "$output" | grep -qF "$needle"; then
        echo "${green}PASS${reset} $test_name"
        PASS=$((PASS + 1))
    else
        echo "${red}FAIL${reset} $test_name"
        echo "       expected error output to contain: [$needle]"
        echo "       got: $(echo "$output" | head -5)"
        FAIL=$((FAIL + 1))
    fi
}

echo "${bold}@@@klammerset tests${reset}"
echo "======================="
echo

# --- Loading ---------------------------------------------------------------

# 1. :files load in list order: klammers.k defines @greet for the target
#    that base.k declares, so base.k must have been read first.
check_eq "1. :files load in order (target before klammer)" \
    "Hello World" \
    --klammersets kit -s '@greet World @' -t fix

# 2. :requires loads the dependency before the set's own files.
check_eq "2. :requires loads the dependency" \
    "--" \
    --klammersets kit -s '@dash@' -t fix

# 3. Program order: a definition AFTER the declaration in the declaring file
#    is available (there is no :text argument; the declaring file's own
#    content plays that role).
check_eq "3. trailing definition in the declaring file" \
    "AFTER" \
    --klammersets kit -s '@after@' -t fix

# 4. Relative :files names resolve against the DECLARING file's directory,
#    not the cwd (run from an unrelated directory).
output=$( (cd /tmp && "$KTEXT" --klammersets kit -s '@greet Elsewhere @' -t fix) 2>/dev/null | trim )
if [ "$output" = "Hello Elsewhere" ]; then
    echo "${green}PASS${reset} 4. :files resolve against the declaring file's directory"
    PASS=$((PASS + 1))
else
    echo "${red}FAIL${reset} 4. :files resolve against the declaring file's directory"
    echo "       expected: [Hello Elsewhere]"
    echo "       got:      [$output]"
    FAIL=$((FAIL + 1))
fi

# 5. A filename with a space in the :files list (standalone "/" separator).
check_eq "5. spacey filename in :files" \
    "SPACEY" \
    --klammersets sp -s '@spacey@' -t fix

# --- A klammerset is identified by where it is -------------------------------
#
# Symbol X must be declared in X/X.k (Andy, 2026-08-15).  The convention already
# governed symbol RESOLUTION; requiring it of the DECLARATION makes the symbol a
# function of the path, which is what lets the already-loaded guard run before a
# file is opened (cases 23-23b below) instead of after it has been re-executed.
#
# It also continues the reasoning behind symbols-only on the command line: if
# every klammerset is reachable by symbol the set of all of them is ENUMERABLE;
# if every declaration is in X/X.k each one is also IDENTIFIABLE from where it
# sits, so a symbol names one file and one file declares one symbol.
#
# "wrap" is the fixture that violates it: wrap/dup.k declares the symbol "kit".
# It formerly tested that such a duplicate was tolerated and SKIPPED; the
# duplicate is now impossible instead, which is the stronger guarantee.
check_fails "6. a declaration outside X/X.k is an error" \
    'must be declared in a file named "kit/kit.k"' \
    --klammersets wrap -s '@greet Again @' -t fix
check_fails "6a. ... and the error names the file it is actually in" \
    'dup.k' \
    --klammersets wrap -s '@greet Again @' -t fix

# 7. EXEMPT: a document may declare a klammerset -- that is how a designer
#    writes one, and kdesc's provenance filter depends on it.  Such a set is
#    local to the document: nothing can ":requires" it, so it has no identity to
#    protect and needs no file named after it.
check_eq "7. a klammerset declared in a document is exempt" \
    "ok" \
    --klammersets none -s '@@@klammerset mine | Local to this document @@@ ok'

# --- Introspection -----------------------------------------------------------

# The machine state moved from "ktext -m" to "kdiag --machine" in the
# 2026-08-14 argument redesign: the Machine's internals are the programmer's
# question, and kdiag is the programmer's command.  kdiag's input is optional,
# so the state can be shown without a document at all.
kdiag_contains() {
    local test_name="$1" expected="$2"; shift 2
    local output
    output=$(kdiag "$@" 2>/dev/null)
    if printf '%s' "$output" | grep -qF -- "$expected"; then
        echo "${green}PASS${reset} $test_name"; PASS=$((PASS + 1))
    else
        echo "${red}FAIL${reset} $test_name"
        echo "       expected to contain: [$expected]"
        FAIL=$((FAIL + 1))
    fi
}

# 8. --machine lists the registered klammersets.  kdiag has no --klammersets:
#    a klammerset reaches it the way anything else does, by being read in the
#    input.  That is deliberate -- the symbol mechanism is a higher-level
#    convenience, and kdiag must still work when symbol resolution is what
#    broke.  (The :requires inside kit.k still uses a symbol, resolved by the
#    Machine, so this exercises both paths at once.)
kdiag_contains "8. --machine shows the klammerset symbol" \
    "kit" \
    "@read $FIX/kit/kit.k @" --process --machine

kdiag_contains "9. --machine shows the required klammerset too" \
    "Utility klammers for engine tests" \
    "@read $FIX/kit/kit.k @" --process --machine

# --- Errors --------------------------------------------------------------------

# 10. A listed file that does not exist is a clean klammerset error.
check_fails "10. missing file in :files" \
    "missing.k" \
    --klammersets broken -s 'x' -t fix

# 11. A symbol must be an identifier (starts with a letter; letters, digits,
#     underscores).
check_fails "11. invalid symbol rejected" \
    "not valid" \
    --klammersets none -s '@@@klammerset 9bad | Bad symbol @@@' -d

# --- The search path (symbol -> <dir>/<symbol>/<symbol>.k) -------------------

# Runtime fixtures: a document directory holding a local klammerset, and a
# separate directory serving as a KLAMMERTEXT_KLAMMERSETS stage.
DOCDIR=$(mktemp -d /tmp/klammerset_doc.XXXXXX)
ENVDIR=$(mktemp -d /tmp/klammerset_env.XXXXXX)
trap 'rm -rf "$DOCDIR" "$ENVDIR" /tmp/klammerset_test_err.$$' EXIT

mkdir -p "$DOCDIR/locset" "$ENVDIR/envset" "$ENVDIR/locset"
printf '@@@klammerset locset | Document-local set @@@\n@@@target fixL | Fixture @@@\n@@local_k.fixL : LOCAL @@\n' \
    > "$DOCDIR/locset/locset.k"
printf '@local_k@\n' > "$DOCDIR/doc.kt"
printf '@@@klammerset envset | Installed set @@@\n@@@target fixE | Fixture @@@\n@@env_k.fixE : ENV @@\n' \
    > "$ENVDIR/envset/envset.k"
printf '@@@klammerset locset | Shadow candidate @@@\n@@@target fixL | Fixture @@@\n@@local_k.fixL : ENV-SHADOWED @@\n' \
    > "$ENVDIR/locset/locset.k"

# 12. Stage 1 for a file input: the document's directory.
check_eq "12. symbol resolves in the document's directory" \
    "LOCAL" \
    "$DOCDIR/doc.kt" --klammersets locset -t fixL -d

# 13. Stage 1 for string input: the cwd stands in for the document.
output=$( (cd "$DOCDIR" && "$KTEXT" -s '@local_k@' --klammersets locset -t fixL) 2>/dev/null | trim )
if [ "$output" = "LOCAL" ]; then
    echo "${green}PASS${reset} 13. cwd stands in for the document (string input)"
    PASS=$((PASS + 1))
else
    echo "${red}FAIL${reset} 13. cwd stands in for the document (string input)"
    echo "       expected: [LOCAL]  got: [$output]"
    FAIL=$((FAIL + 1))
fi

# 14. Stage 2: the KLAMMERTEXT_KLAMMERSETS directories.
output=$(KLAMMERTEXT_KLAMMERSETS=$ENVDIR "$KTEXT" -s '@env_k@' --klammersets envset -t fixE 2>/dev/null | trim)
if [ "$output" = "ENV" ]; then
    echo "${green}PASS${reset} 14. symbol resolves in KLAMMERTEXT_KLAMMERSETS"
    PASS=$((PASS + 1))
else
    echo "${red}FAIL${reset} 14. symbol resolves in KLAMMERTEXT_KLAMMERSETS"
    echo "       expected: [ENV]  got: [$output]"
    FAIL=$((FAIL + 1))
fi

# 15. Shadowing: the document-local set wins over the installed one.
output=$(KLAMMERTEXT_KLAMMERSETS=$ENVDIR "$KTEXT" "$DOCDIR/doc.kt" --klammersets locset -t fixL -d 2>/dev/null | trim)
if [ "$output" = "LOCAL" ]; then
    echo "${green}PASS${reset} 15. document-local set shadows the installed one"
    PASS=$((PASS + 1))
else
    echo "${red}FAIL${reset} 15. document-local set shadows the installed one"
    echo "       expected: [LOCAL]  got: [$output]"
    FAIL=$((FAIL + 1))
fi

# 16. :requires by symbol, resolved with the declaring directory as the
#     local stage (the required set sits inside the declaring set's dir).
mkdir -p "$DOCDIR/kit2/locset"
cp "$DOCDIR/locset/locset.k" "$DOCDIR/kit2/locset/locset.k"
printf '@@@klammerset kit2 | Requires by symbol :requires locset @@@\n' > "$DOCDIR/kit2/kit2.k"
output=$(KLAMMERTEXT_KLAMMERSETS=$DOCDIR "$KTEXT" --klammersets kit2 \
    -s '@local_k@' -t fixL 2>/dev/null | trim)
if [ "$output" = "LOCAL" ]; then
    echo "${green}PASS${reset} 16. :requires accepts a symbol (declaring-dir stage)"
    PASS=$((PASS + 1))
else
    echo "${red}FAIL${reset} 16. :requires accepts a symbol (declaring-dir stage)"
    echo "       expected: [LOCAL]  got: [$output]"
    FAIL=$((FAIL + 1))
fi

# 17. Stage 3: $KLAMMERTEXT_HOME — the kdesc listing enumerates sks
#     (sks/sks.k already satisfies the <symbol>/<symbol>.k convention).
output=$(kdesc --klammersets 2>/dev/null)
if printf '%s' "$output" | grep -q 'sks/sks\.k'; then
    echo "${green}PASS${reset} 17. kdesc --klammersets lists sks from KLAMMERTEXT_HOME"
    PASS=$((PASS + 1))
else
    echo "${red}FAIL${reset} 17. kdesc --klammersets lists sks from KLAMMERTEXT_HOME"
    echo "       got: $(echo "$output" | head -3)"
    FAIL=$((FAIL + 1))
fi

# 18. An unknown symbol is a clean error, and it names both the convention and
#     the directories searched -- without those a user cannot tell whether the
#     name or the location is wrong.  This is the failure mode the symbol-only
#     rule makes common: there is no longer a pathname to fall back on.
check_fails "18. unknown symbol is a clean error" \
    "was not found" \
    -s 'x' --klammersets nosuchset -d
check_fails "18a. ... naming the <symbol>/<symbol>.k convention" \
    "x/x.k" \
    -s 'x' --klammersets nosuchset -d
check_fails "18b. ... and the directories searched" \
    "$FIX" \
    -s 'x' --klammersets nosuchset -d

# 19. A pathname is no longer accepted: only symbols resolve.  The error must
#     say so rather than reporting a missing file.
check_fails "19. a pathname is rejected as a symbol" \
    "not found" \
    -s 'x' --klammersets "$FIX/kit/kit.k" -d

# 20. One bad symbol among good ones fails the whole load rather than
#     silently loading the rest.
check_fails "20. a bad symbol among good ones still fails" \
    "was not found" \
    -s 'x' --klammersets kit nosuchset -t fix -d

# --- Several klammersets combine ---------------------------------------------
#
# The headline claim of "--klammersets" (plural), stated in the ktext usage and
# in "kdesc --klammersets help": the symbols are a LIST, loaded in the order
# given, and the klammers of all of them share ONE flat namespace -- membership
# is provenance, not containment.  Where two sets define the same klammer the
# definition modes decide, so a set of house overrides is loaded after the set
# it adjusts and a set of defaults before it.
#
# The fixtures: "base" declares the target and the ".k", "housea" and "houseb"
# each require base and each override @who with ":::".  Both requiring base is
# what lets either be written first, so command-line order is the only variable.

# 21. One namespace: each set's own klammers are available whatever the order.
check_eq "21. combined sets share one namespace" \
    "ONLY-A ONLY-B" \
    --klammersets housea houseb -s '@a_only@ @b_only@' -t fx
check_eq "21a. ... in either order" \
    "ONLY-A ONLY-B" \
    --klammersets houseb housea -s '@a_only@ @b_only@' -t fx

# 22. Order decides: two ":::" overrides of the same klammer, so the set
#     written LAST is the one whose definition survives.
check_eq "22. the last set written wins an override" \
    "HOUSE-B" \
    --klammersets housea houseb -s '@who@' -t fx
check_eq "22a. ... and reversing the order reverses the outcome" \
    "HOUSE-A" \
    --klammersets houseb housea -s '@who@' -t fx

# 23. :requires is loaded once even though both sets ask for it -- the
#     already-loaded guard -- so the base definition is there exactly once.
check_eq "23. a set required by both is loaded once" \
    "BASE" \
    --klammersets base -s '@who@' -t fx

# 23a. A DIAMOND: two sets requiring a third.  "needs_a" and "needs_b" both
#      require "selfdef", whose declaring file holds its own @@@target -- which
#      is legal and normal, since there is deliberately no :text option and the
#      declaring file's content plays that role.
#
#      This failed until 2026-08-15 with "Target fy is already defined",
#      pointing at a line the author wrote once.  The already-loaded guard sat
#      in Klammerset_registry::add, which runs only after a file has been read
#      and its declaration reached -- by which time the second read had
#      re-executed the declaring file's own definitions.  The guard now runs
#      BEFORE the read, which is possible only because X is declared in X/X.k
#      and the symbol is therefore known from the path.
check_eq "23a. a diamond :requires loads the shared set once" \
    "SELFDEF" \
    --klammersets needs_a needs_b -s '@sd@' -t fy
check_eq "23b. ... and requiring it once is unchanged" \
    "SELFDEF" \
    --klammersets needs_a -s '@sd@' -t fy
check_eq "23c. ... as is naming the same set twice on the command line" \
    "SELFDEF" \
    --klammersets selfdef selfdef -s '@sd@' -t fy

# 23d. The search path is FROZEN for the run.  A path decides WHICH FILE a
#      symbol means -- identity, not value -- so if a document could extend it
#      mid-run, "which klammerset is X" would depend on evaluation order and
#      "kdesc --klammersets" could not be a complete answer.  It could:
#      the embedded Python shares the process, so an @eval doing os.environ
#      changed what a later getenv returned.  The environment is now read once.
LATE=$(mktemp -d /tmp/klammerset_late.XXXXXX)
mkdir -p "$LATE/late"
printf '@@@klammerset late | Appeared mid-run @@@\n@@@target lt | T @@@\n@@l.lt : LATE @@\n' \
    > "$LATE/late/late.k"
# The @eval still sets the variable -- what changed is that resolution no
# longer consults it -- so the symbol must remain unfindable.
out=$(KLAMMERTEXT_KLAMMERSETS=$FIX "$KTEXT" --klammersets none -d \
        -s "@eval os.environ.__setitem__('KLAMMERTEXT_KLAMMERSETS','$LATE') or '' @" 2>&1)
if printf '%s' "$out" | grep -q "was not found" || \
   ! KLAMMERTEXT_KLAMMERSETS=$FIX "$KTEXT" --klammersets late -s 'x' -d >/dev/null 2>&1; then
    echo "${green}PASS${reset} 23d. an @eval cannot extend the klammerset search path"
    PASS=$((PASS + 1))
else
    echo "${red}FAIL${reset} 23d. the search path moved during the run"
    FAIL=$((FAIL + 1))
fi
rm -rf "$LATE"

# --- "none" is the absence of a klammerset, not the name of one ---------------
#
# It therefore says nothing about what should be loaded alongside it.  Written
# first it used to discard the rest of the list silently -- the user asked for
# a set, got none, and was told only that some target was undefined; written
# last it was looked up as a symbol and reported missing.  Neither reading is
# guessed at now: "none" is accepted only alone.

# 24. "none" alone still loads nothing, which is what it is for.
check_eq "24. none alone loads no klammerset" \
    "x" \
    --klammersets none -s 'x' -d

check_fails "25. none before another symbol is an error" \
    "cannot be combined" \
    --klammersets none kit -s 'x' -t fix -d
check_fails "25a. none after another symbol is an error too" \
    "cannot be combined" \
    --klammersets kit none -s 'x' -t fix -d

# 26. The rule belongs to load_klammersets(), the single loader all three
#     commands share -- so kdesc obeys it as well.  It did not: kdesc decided
#     what "none" meant a second time, and so accepted "none sks" and loaded
#     neither.  Deciding it twice is how the two commands came to disagree.
out=$(kdesc --klammersets none sks -k table 2>&1); status=$?
if [ $status -ne 0 ] && printf '%s' "$out" | grep -qF "cannot be combined"; then
    echo "${green}PASS${reset} 26. kdesc applies the same none rule"
    PASS=$((PASS + 1))
else
    echo "${red}FAIL${reset} 26. kdesc applies the same none rule — exit $status"
    echo "       got: $(printf '%s' "$out" | head -2)"
    FAIL=$((FAIL + 1))
fi

echo
echo "======================="
echo "Results: ${green}$PASS passed${reset}, $([ $FAIL -gt 0 ] && echo "${red}$FAIL failed${reset}" || echo "0 failed")"
[ $FAIL -eq 0 ]
