#!/bin/bash
#
# test-jxl - exercise dotlib/jxl-lib.sh, the JXL shell library
#
# Usage: ./dots/tests/test-jxl   (or via ./dots/tests/run-tests)
#
# Every case runs under every shell build select_test_shells finds -- system bash 3.2 and
# Homebrew bash 5.x, system and Homebrew zsh -- and all of them must agree. The library is
# sourced by rc files in either shell, and the things that differ are all silent: array
# subscript bases, empty-array expansion under nounset, whether a `local` on an exported
# variable leaks to child processes, and bash 4 syntax that 3.2 accepts at parse time and
# only rejects when the line runs. A single-shell run would miss exactly the bugs this
# library exists to prevent.

set -o errexit
set -o nounset
set -o pipefail

cd "$(dirname "$0")/../.."
REPO="$PWD"

# shellcheck source=dots/tests/lib.sh
source "$REPO/dots/tests/lib.sh"

LIB="$REPO/dots/all-os/flat/dotlib/jxl-lib.sh"

# shellcheck disable=SC2016  # the single-quoted snippets are code for the inner shell
function main() {
  select_test_shells
  print_test_env

  echo "===== messaging ====="
  assert_all_shells 'jxl::info hello 2>/dev/null; echo done' 'done' \
      'info writes nothing to stdout'
  assert_all_shells 'jxl::info hello 2>&1 >/dev/null' 'hello' \
      'info writes the message to stderr'
  assert_all_shells 'jxl::error boom 2>&1 >/dev/null' 'ERROR: boom' \
      'error is prefixed'
  assert_all_shells \
      'PROGRAM_NAME=prog; _JXL_EMIT_PROGNAME=1; jxl::info hi 2>&1 >/dev/null' \
      'prog: hi' '_JXL_EMIT_PROGNAME prefixes the program name'

  echo "===== verbosity and dry run ====="
  assert_all_shells '_JXL_VERBOSE=0; jxl::log_vrb quiet 2>&1; echo end' 'end' \
      'log_vrb is silent when not verbose'
  assert_all_shells '_JXL_VERBOSE=1; jxl::log_vrb loud 2>&1 >/dev/null' 'loud' \
      'log_vrb speaks when verbose'
  assert_all_shells \
      '_JXL_DRY_RUN=1; jxl::wet echo SHOULD-NOT-RUN 2>/dev/null; echo end' 'end' \
      'wet does not run the command on a dry run'
  assert_all_shells '_JXL_DRY_RUN=0; jxl::wet echo ran 2>/dev/null' 'ran' \
      'wet runs the command otherwise'

  echo "===== array accessors ====="
  assert_all_shells 'a=(zero one two); jxl::array_at v a 0; echo "$v"' 'zero' \
      'array_at is 0-based in both shells'
  assert_all_shells 'a=(zero one two); jxl::array_at v a 2; echo "$v"' 'two' \
      'array_at reaches the last element'
  assert_all_shells 'a=(x "b c" y); jxl::array_at v a 1; echo "[$v]"' '[b c]' \
      'array_at preserves spaces'
  assert_all_shells 'a=(x "-lead" y); jxl::array_at v a 1; echo "[$v]"' '[-lead]' \
      'array_at preserves a leading dash'
  assert_all_shells 'a=(x "s*t\$u" y); jxl::array_at v a 1; echo "[$v]"' '[s*t$u]' \
      'array_at preserves glob and dollar characters'
  assert_all_shells \
      'a=(one two); jxl::array_at v a 9 2>/dev/null || echo "rc=$?"' 'rc=1' \
      'array_at rejects an out-of-range index'
  assert_all_shells 'a=(one two three); jxl::array_len n a; echo "$n"' '3' \
      'array_len counts elements'
  assert_all_shells 'set -o nounset; e=(); jxl::array_len n e; echo "$n"' '0' \
      'an empty array under nounset does not explode'

  echo "===== emulated associative arrays ====="
  assert_all_shells 't=(k1 v1 k2 v2); jxl::assocarray_at x t k2; echo "$x"' 'v2' \
      'assocarray_at finds a key'
  assert_all_shells 't=(k1 "a value" k2 v2); jxl::assocarray_at x t k1; echo "[$x]"' \
      '[a value]' 'assocarray_at preserves spaces in values'
  assert_all_shells \
      't=(k1 v1); jxl::assocarray_at x t nope 2>/dev/null || echo "rc=$?"' \
      'rc=1' 'assocarray_at returns 1 for a missing key'
  assert_all_shells \
      't=(k1 v1 k2); jxl::assocarray_at x t k1 2>/dev/null || echo "rc=$?"' \
      'rc=2' 'assocarray_at rejects an odd-length table'
  assert_all_shells 't=(k1 v1); jxl::assocarray_has t k1 && echo yes' 'yes' \
      'assocarray_has finds a key'
  assert_all_shells 't=(k1 v1); jxl::assocarray_has t nope || echo no' 'no' \
      'assocarray_has reports a missing key quietly'
  assert_all_shells \
      'set -o nounset; t=(k1 v1)
       jxl::assocarray_at x t nope 2>&1 >/dev/null | head -1' \
      'ERROR: jxl::assocarray_at: $t has no key: nope' \
      'a missing key reports an error under nounset'

  echo "===== command functions: arguments ====="
  assert_all_shells "$DEMO"'
    function jx::demo() { echo "long=$do_long verbose=$_JXL_VERBOSE"; }
    jx-demo' 'long=0 verbose=0' 'a command function runs with defaults'
  assert_all_shells "$DEMO"'
    function jx::demo() { echo "long=$do_long verbose=$_JXL_VERBOSE"; }
    jx-demo --long --verbose' 'long=1 verbose=1' \
      'the runner parses standard options and the command parses its own'
  assert_all_shells "$DEMO"'
    function jx::demo() { echo SHOULD-NOT-RUN; }
    jx-demo --bogus 2>/dev/null || echo "rc=$?"' 'rc=1' \
      'an unconsumed argument is rejected before the impl runs'
  assert_all_shells "$DEMO"'
    function jx::demo() { echo NOPE; }
    jx-demo --help 2>&1 | grep -c -- "--verbose"' '1' \
      'help includes the shared standard options block'
  assert_all_shells "$DEMO"'
    function jx::demo() { echo NOPE; }
    jx-demo --help 2>&1 | grep -c "jx-demo - Demo"' '1' \
      'help uses the headline and the auto-detected command name'

  echo "===== command functions: isolation ====="
  assert_all_shells "$DEMO"'
    function jx::demo() { LEAKED=yes; }
    LEAKED=no; jx-demo; echo "$LEAKED"' 'no' \
      'subshell mode contains the impl'"'"'s variable assignments'
  # No `|| true` on the jx-demo call: bash suppresses errexit through the whole dynamic
  # extent of a command in a && / || list, including inside functions it calls, so the
  # guard would disable the very thing under test.
  assert_all_shells "$DEMO"'
    function jx::demo() { set -o errexit; false; echo NOT-REACHED; }
    jx-demo
    if [[ -o errexit ]]; then echo LEAKED; else echo clean; fi' 'clean' \
      'subshell mode contains errexit set by the impl'
  assert_all_shells "$DEMO"'
    function jx::demo() { jxl::die "fatal"; }
    jx-demo 2>/dev/null || echo "rc=$?"; echo alive' \
      'rc=1
alive' 'jxl::die in subshell mode does not kill the calling shell'
  assert_all_shells '
    function jx-inline() {
      local -a _JXL_COMMAND_INFO=( isolation inline )
      jxl::run_command jx::inline "$@"
    }
    function jx::inline() { PERSISTED=yes; }
    PERSISTED=no; jx-inline; echo "$PERSISTED"' 'yes' \
      'inline mode lets the impl change the calling shell'
  assert_all_shells '
    function jx-inline() {
      local -a _JXL_COMMAND_INFO=( isolation inline )
      jxl::run_command jx::inline "$@"
    }
    function jx::inline() { :; }
    jx-inline
    if [[ -o pipefail ]]; then echo LEAKED; else echo clean; fi' 'clean' \
      'inline mode restores pipefail afterward'

  echo "===== command functions: shell options are normalized ====="
  # A subshell inherits every option from its parent, so without normalizing, an impl
  # would behave differently depending on what the user had set interactively.
  assert_all_shells "$DEMO"'
    function jx::demo() {
      if [[ -o nounset ]]; then echo "nounset=on"; else echo "nounset=off"; fi
    }
    set +o nounset
    jx-demo' 'nounset=on' 'an impl gets nounset even when the caller had it off'
  assert_all_shells "$DEMO"'
    function jx::demo() {
      if [[ -o pipefail ]]; then echo "pipefail=on"; else echo "pipefail=off"; fi
    }
    set +o pipefail
    jx-demo' 'pipefail=on' 'an impl gets pipefail even when the caller had it off'
  assert_all_shells "$DEMO"'
    function jx::demo() {
      if [[ -o errexit ]]; then echo "errexit=on"; else echo "errexit=off"; fi
    }
    set -o errexit
    jx-demo' 'errexit=off' 'an impl starts without errexit even when the caller had it on'

  echo "===== hostile shell options do not reach the impl ====="
  # A command function inherits the interactive session's options. These are the ones that
  # change what the impl's own code MEANS, not just how it looks.
  assert_all_shells "$DEMO"'
    shopt -s nocasematch 2>/dev/null || setopt nocasematch 2>/dev/null || true
    function jx::demo() { case ABC in abc) echo loose ;; *) echo strict ;; esac; }
    jx-demo' 'strict' 'nocasematch does not reach the impl'
  assert_all_shells "$DEMO"'
    set -o allexport
    function jx::demo() { SOME_THING=1; env | grep -c "^SOME_THING=" || true; }
    jx-demo' '0' 'allexport does not reach the impl, so assignments stay unexported'
  # Split by family: bash's default for an unmatched glob is the literal pattern, zsh's is
  # to error, so "did nullglob leak" has no expected value the two could share.
  assert_one_family bash "$DEMO"'
    shopt -s nullglob
    function jx::demo() { a=(zzz-no-such-glob*); echo "${#a[@]}"; }
    jx-demo' '1' 'nullglob does not reach the impl'
  assert_one_family zsh "$DEMO"'
    setopt null_glob
    function jx::demo() { a=(zzz-no-such-glob*); echo "${#a[@]}"; }
    jx-demo 2>&1 | grep -c "no matches found"' '1' \
      'null_glob does not reach the impl'
  # noclobber is the exception: a guard rail the caller set on purpose, so it is carried
  # through rather than reset. Under zsh that means putting it back after `emulate -R`,
  # which would otherwise take it along with everything else.
  assert_all_shells "$DEMO"'
    set -o noclobber
    function jx::demo() { if [[ -o noclobber ]]; then echo kept; else echo lost; fi; }
    jx-demo' 'kept' 'noclobber is preserved, since the caller set it deliberately'
  assert_all_shells "$DEMO"'
    set +o noclobber
    function jx::demo() { if [[ -o noclobber ]]; then echo on; else echo off; fi; }
    jx-demo' 'off' 'noclobber stays off when the caller did not set it'

  echo "===== zsh emulation mode is normalized ====="
  # zsh-only: bash has no `emulate`, and the two shells cannot agree on an expected value
  # here by construction -- ${a[1]} is the first element under zsh defaults and the second
  # everywhere else, which is exactly the confusion being guarded against.
  assert_one_family zsh "$DEMO"'
    setopt ksh_arrays
    function jx::demo() { a=(x y z); echo "${a[1]}"; }
    jx-demo' 'x' 'a session ksh_arrays does not reach the impl'
  assert_one_family zsh "$DEMO"'
    emulate sh
    function jx::demo() { a=(x y z); echo "${a[1]}"; }
    jx-demo' 'x' 'a session running emulate sh does not reach the impl'

  echo "===== VERBOSE and DEBUG are read-only interface ====="
  assert_all_shells "$DEMO"'
    function jx::demo() { env | grep "^VERBOSE=" || echo "VERBOSE=<absent>"; }
    export VERBOSE=0
    jx-demo --verbose' 'VERBOSE=0' \
      '--verbose does not leak into the environment of child processes'
  assert_all_shells "$DEMO"'
    function jx::demo() { echo "$_JXL_VERBOSE"; }
    export VERBOSE=1
    jx-demo' '1' 'an exported VERBOSE is inherited as the internal verbosity'
  assert_all_shells "$DEMO"'
    function jx::demo() { echo "$_JXL_VERBOSE"; }
    export VERBOSE=1 JXL_VERBOSE=0
    jx-demo' '0' 'JXL_VERBOSE overrides VERBOSE'

  echo "===== short names and the include guard ====="
  assert_all_shells 'typeset -f info >/dev/null 2>&1 && echo defined || echo undefined' \
      'undefined' 'sourcing alone does not claim the short names'
  assert_all_shells 'jxl::use_short_names; info hi 2>&1 >/dev/null' 'hi' \
      'use_short_names defines the wrappers on request'
  assert_all_shells 'jxl::use_short_names --dry-run 2>/dev/null
    if jxl::_is_function info; then echo defined; else echo undefined; fi' 'undefined' \
      '--dry-run defines nothing'
  assert_all_shells 'function info() { echo MINE; }
    jxl::use_short_names --safe 2>/dev/null
    info' 'MINE' '--safe refuses to clobber an existing function'
  assert_all_shells '_JXL_DEBUG=1; jxl::use_short_names 2>&1 >/dev/null | head -1' \
      'debug: Defined short-name JXL functions. Shadowed commands: info.' \
      'use_short_names reports what it shadowed'
  assert_all_shells 'jxl::use_short_names 2>/dev/null
    if jxl::_is_function tic; then echo claimed; else echo left-alone; fi' 'left-alone' \
      'tic is left alone, since /usr/bin/tic is a real command'

  echo "===== show_var ====="
  assert_all_shells 'JX_S=hi; jxl::show_var JX_S' '  JX_S                    = hi' \
      'show_var renders a plain scalar with no sigil'
  assert_all_shells 'jxl::show_var JX_MISSING' '  JX_MISSING              (unset)' \
      'show_var reports an unset name'
  assert_all_shells \
      'export JX_E=1; jxl::show_var JX_E "$(export -p)"' '  JX_E                 ^  = 1' \
      'show_var marks an exported scalar with ^'
  assert_all_shells 'JX_S=1; jxl::show_var JX_S "$(export -p)"' \
      '  JX_S                    = 1' \
      'show_var puts no ^ on an unexported scalar, even when EXPORTS is given'
  assert_all_shells "a=(one two); jxl::show_var a" "  a                    @  = ( one two )" \
      'show_var marks an array with @ and renders every element'
  assert_all_shells 'a=(x "c d"); jxl::show_var a' "  a                    @  = ( x 'c d' )" \
      'show_var quotes an array element containing a space'
  assert_all_shells \
      'a=(x y); export a; jxl::show_var a "$(export -p)"' \
      '  a                    @^ = ( x y )' \
      'show_var combines @ and ^ for an exported array, type sigil first'
  assert_all_shells 'JX_M=$(printf "one\ntwo"); jxl::show_var JX_M' \
      "$(printf '  JX_M                    = one\n      two')" \
      'show_var indents continuation lines of a multi-line value'
  # zsh-only snippet: bash 3.2 has no associative arrays. Safe -- assert_one_family only
  # ever runs this under zsh.
  zsh_assoc_snippet='typeset -A t=(k v); jxl::show_var t'  # jx-lint-ok: bash4
  assert_one_family zsh "$zsh_assoc_snippet" "  t                    %  = ( [k]=v )" \
      'show_var marks a zsh associative array with %'

  echo "===== show_shell_info ====="
  assert_all_shells 'jxl::show_shell_info | head -1' "JXL $(cat <<<'0.1.0')" \
      'show_shell_info leads with the version'
  assert_all_shells 'jxl::show_shell_info | grep -c "loaded from: .*jxl-lib.sh"' '1' \
      'show_shell_info names the file that was sourced'
  # Compare depths rather than asserting an absolute one: any way of capturing the output
  # is itself a subshell, so the reported number includes the harness's own pipeline. The
  # difference is what carries the meaning.
  assert_all_shells "$DEMO"'
    function depth_now() {
      jxl::show_shell_info | grep "subshell depth:" | sed -e "s/.*depth: //"
    }
    top=$(depth_now)
    function jx::demo() { depth_now; }
    inner=$(jx-demo)
    echo $(( inner - top ))' '1' \
      'show_shell_info sees one more subshell level inside an isolated impl'
  assert_all_shells "$DEMO"'
    function jx::demo() { jxl::show_shell_info | grep -c "Call stack:.*jx-demo"; }
    jx-demo' '1' 'show_shell_info shows the command function on the call stack'
  assert_all_shells "$DEMO"'
    function jx::demo() { jxl::show_shell_info | grep -c "PROGRAM_NAME *= jx-demo"; }
    jx-demo' '1' 'show_shell_info reports the invocation state'
  assert_all_shells 'jxl::show_shell_info | grep -c "PROGRAM_NAME *(unset)"' '1' \
      'invocation state reads as unset outside a command'
  assert_all_shells 'jxl::use_short_names >/dev/null 2>&1
    jxl::show_shell_info | grep -c "JXL wrappers: info"' '1' \
      'show_shell_info reports which short names are JXL wrappers'
  assert_all_shells 'function info() { :; }
    jxl::show_shell_info | grep -c "defined but NOT JXL: info"' '1' \
      'show_shell_info distinguishes a foreign definition from a JXL wrapper'
  assert_all_shells 'export VERBOSE=1
    jxl::show_shell_info | grep -c "VERBOSE *\^ *= 1"' \
      '1' 'show_shell_info marks exported interface variables with the ^ sigil'

  echo "===== version and load notice ====="
  assert_all_shells 'echo "$_JXL_VERSION"' '0.1.0' 'sourcing sets the version'
  assert_all_shells 'echo "${_JXL_VERSION_ARR[*]}"' '0 1 0' \
      'the version is also available pre-split'
  assert_all_shells 'JXL_DEBUG=1; source "'"$LIB"'" 2>&1 | head -1' '' \
      'the load notice stays quiet on a guarded re-source'

  echo "===== command info drives the log format ====="
  assert_all_shells '
    function jx-t() {
      local -a _JXL_COMMAND_INFO=( emit_progname 1 )
      jxl::run_command jx::t "$@"
    }
    function jx::t() { jxl::info hello; }
    jx-t 2>&1 >/dev/null' 'jx-t: hello' \
      '_JXL_COMMAND_INFO emit_progname sets the message format'
  assert_all_shells 'source "'"$LIB"'"; echo "$_JXL_VERSION"' '0.1.0' \
      'sourcing twice is a no-op'

  echo "===== sourcing does not pollute the shell ====="
  # jxl-lib.sh is sourced into every interactive shell by bashyrc.sh, so $_JXL_VERSION had
  # better be the only thing it leaves behind. Everything else is per-invocation state.
  assert_all_shells '
    for v in _JXL_VERBOSE _JXL_DEBUG _JXL_DRY_RUN _JXL_WANT_HELP \
             _JXL_EMIT_PROGNAME _JXL_EMIT_TIMESTAMP PROGRAM_NAME; do
      eval "if [[ -n \${$v+x} ]]; then echo \"$v IS SET\"; fi"
    done
    echo clean' 'clean' 'sourcing sets nothing but _JXL_VERSION'
  assert_all_shells '
    function jx-inline() {
      local -a _JXL_COMMAND_INFO=( isolation inline )
      jxl::run_command jx::inline "$@"
    }
    function jx::inline() { :; }
    jx-inline --verbose
    if [[ -n ${_JXL_VERBOSE+x} ]]; then echo LEAKED; else echo clean; fi' 'clean' \
      'even inline mode leaves no JXL state in the calling shell'
}

# A command function used by several cases above. Kept in one string so the isolation and
# argument-parsing tests all exercise the same shape a real command has.
#
# shellcheck disable=SC2016  # single-quoted $... is source code for the inner shell
DEMO='
function jx-demo() {
  local do_long=0
  local -a _JXL_COMMAND_INFO=( usage_headline "Demo" synopsis_args "[--long]" )
  jxl::run_command jx::demo "$@"
}
function jx::demo_parse_cli() {
  local arg
  local -a leftover=()
  while [[ $# -ge 1 ]]; do
    arg="$1"; shift
    case "$arg" in
      --long | -l)  do_long=1 ;;
      *)            leftover+=("$arg") ;;
    esac
  done
  _JXL_CLI_ARGS=( ${leftover[@]+"${leftover[@]}"} )
}
'

function run_in() {
  # Echoes the snippet's combined output, run under the given shell with JXL sourced.
  #
  # The `|| true` matters: several snippets deliberately end in a failing state, and this
  # script runs under errexit, so without it a non-zero snippet aborts the whole run at
  # the `got=$(run_in ...)` assignment instead of failing that one assertion.
  local shell="$1" snippet="$2"
  "$shell" -c "
    source '$LIB'
    $snippet
  " 2>&1 || true
}

function assert_all_shells() {
  # Every shell in $TEST_SHELLS must agree. Reports the first that disagrees, naming its
  # version -- "bash disagreed" is not much use with two bash builds in play.
  local snippet="$1" want="$2" desc="$3"
  local sh got
  for sh in "${TEST_SHELLS[@]}"; do
    got=$(run_in "$sh" "$snippet")
    if [[ "$got" != "$want" ]]; then
      fail "$desc" "${sh##*/} $(shell_version "$sh"): got '$got', wanted '$want'"
      return
    fi
  done
  ok "$desc"
}

function assert_one_family() {
  # assert_one_family bash|zsh SNIPPET WANT DESC
  #
  # For behavior the two families could not share an expected value for even in
  # principle -- zsh has no shopt, bash has no emulate, and their defaults for an
  # unmatched glob differ (bash leaves the pattern, zsh errors).
  local family="$1" snippet="$2" want="$3" desc="$4"
  local sh got ran=0
  for sh in "${TEST_SHELLS[@]}"; do
    case "${sh##*/}" in "$family") ;; *) continue ;; esac
    ran=1
    got=$(run_in "$sh" "$snippet")
    if [[ "$got" != "$want" ]]; then
      fail "$desc" "${family} $(shell_version "$sh"): got '$got', wanted '$want'"
      return
    fi
  done
  if [[ $ran == 0 ]]; then
    fail "$desc" "no ${family} build found to test under"
    return
  fi
  ok "$desc"
}

main
print_summary
exit $(( FAILS > 0 ? 1 : 0 ))
