#!/bin/bash
#
# Bash library that use jq (Command-line JSON processor) to convert json data to bash variables or arrays, and vice-versa.
#
# Copyright © 2024 Jean-Jacques Brucker <jjbrucker@foopgp.org>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
# shellcheck disable=SC2013 # To read lines rather than words
# shellcheck disable=SC2034 # variable appears unused
# shellcheck disable=SC2046 # Quote this to prevent word splitting
# shellcheck disable=SC2086 # Double quote warning
# shellcheck disable=SC2128 # expanding array without index warning


if [[ "$1" == --bash-completion ]] ; then
	BL_tmp_a=$("${BASH_SOURCE[0]}" --help | sed -nE 's:^ {2,4}([a-z0-9_]+\>).*:\1:p')
	BL_tmp_o=$(for a in $BL_tmp_a ; do
		echo "[$a]=\"$(eval echo $("$BASH_SOURCE" $a --help | sed -n 's,^  ... \(--[a-z_-]\+[^ ]*\).*,\1,p' | sed 's_<_{_ ; s_|_,_g ; s_>_}_ '))\""
	done)

	eval '_bl_json_completion()
	{
		local cur coptions="--help --version"
		local a actions="'$BL_tmp_a'"
		local -A aoptions=('$BL_tmp_o')

		COMPREPLY=()
		cur=${COMP_WORDS[COMP_CWORD]}

		case ${COMP_WORDS[COMP_CWORD-1]} in
			--help|--version)
				# should exit without executing any actions, so do not complete anything
				return 0 ;;
		esac
		case $cur in
			-*)
				if a=$(grep -o "\<\('${BL_tmp_a//$'\n'/\\|}'\)\>" <<< "${COMP_WORDS[@]}") ; then
					COMPREPLY=( $(compgen -W "$coptions ${aoptions[$a]}" -- $cur ) )
					return 0
				fi
				COMPREPLY=( $(compgen -W "$coptions" -- $cur ) )
				return 0 ;;
		esac
		# complete actions if none in ${COMP_WORDS[@]}
		grep -q "\<\('${BL_tmp_a//$'\n'/\\|}'\)\>" <<< "${COMP_WORDS[@]}" || COMPREPLY=( $(compgen -W "$actions" -- $cur ) )
		return 0
	}'
	unset BL_tmp_a BL_tmp_o
	complete -F _bl_json_completion "$(basename "$BASH_SOURCE")" "$BASH_SOURCE"
	return 0
fi

# If sourcing while _bl_json_parseoptions is already set, execute the function and return without reloading rest of file.
if [[ "$BASH_SOURCE" != "$0" ]] && [[ "$(type -t _bl_json_parseoptions)" == function ]] ; then
	_bl_json_parseoptions "$@"
	return $?
fi

### Constants ###

BL_JSON_NAME="$(basename "$(readlink -f "$BASH_SOURCE")" )"
BL_JSON_VERSION="0.3.5-1"
BL_JSON_FUNCTIONS=( $(sed -n 's,^\(bl_[^( ]*\) *().*,\1,p' "$BASH_SOURCE") )
readonly BL_JSON_NAME BL_JSON_VERSION BL_JSON_FUNCTIONS

[[ "$BASH_SOURCE" == "$0" ]] && declare -r BL_JSON_isprogram=1 || declare -r BL_JSON_isprogram=0

### Others Globals ###

if ((BL_JSON_isprogram)) ; then
	TEXTDOMAIN="bashlibs"
	TEXTDOMAINDIR="$(dirname "$(readlink -f "$BASH_SOURCE")" )/../share/locale"
	# Require Bash 5.2. - https://www.kurokatta.org/grumble/2023/11/bash-translated-strings#fnref3
	shopt -s noexpand_translation
fi

BL_JSON_chelpmsg="
"$"Use jq (Command-line JSON processor) to convert json data to bash variables or arrays, and vice-versa.""

MAIN OPTIONS:"

### external functions ###

### internal functions ###

_bl_json_parseoptions() {
	local npp=$#
	for ((;$#;)) ; do
		case "$1" in
			-h|--help) printf "%s\n%s%s\n" "$BL_JSON_usage" "$BL_JSON_chelpmsg" "$BL_JSON_shelpmsg" ; return 1 ;;
			-V|--version) printf "%s %s\n" "$BL_JSON_NAME" "$BL_JSON_VERSION" ; return 1 ;;
			--) shift ; break ;;
			-*) printf "%s: unrecognized option '%s'\n\nTry '%s --help' for more information.\n" "$BL_JSON_NAME" "$1" "$BASH_SOURCE" >&2 ; return 2 ;;
			*) break ;;
		esac
		shift
	done
	BL_JSON_NOPTIONS=$((npp-$#))
}

### public functions / program actions ###

bl_json_from_str() {
	local name
	((BL_JSON_isprogram)) && name="$BL_JSON_NAME ${FUNCNAME:8}" || name="$FUNCNAME"
	local usage="Usage: $name [--] [STRING]..."
	local helpmsg="
Escape '\\' and [/\"\b\f\n\r\t] characters as required by JSON format.
"$"If there is no STRING in command line, read it from stdin.""
"

	for ((;$#;)) ; do
		case "$1" in
			-h|--help) printf "%s\n%s\n" "$usage" "$helpmsg" ; return ;;
			-V|--version) echo "$FUNCNAME $BL_JSON_VERSION" ; return ;;
			--) shift ; break ;;
			-*) printf "$FUNCNAME: Error: "$"Unrecognized option"" '$1'.\n\n"$"Try '%s --help' for more information"".\n" "$name" >&2 ; return 2 ;;
			*) break ;;
		esac
	done

	local from='cat'
	! (($#)) || from='eval echo "$@"'
	$from | sed 's,\\,\\\\,g; s,/,\\/,g; s,",\\",g; s,\x08,\\b,g; s,\x0c,\\f,g; s,\r,\\r,g; s,\t,\\t,g;' | sed ':a;N;$!ba; s,\n,\\n,g;'
}

bl_json_from_var() {
	local name
	((BL_JSON_isprogram)) && name="$BL_JSON_NAME ${FUNCNAME:8}" || name="$FUNCNAME"
	local usage="Usage: $name [OPTIONS] VARNAME..."
	local helpmsg="
"$"Output VARNAMES in a JSON object.""

OPTIONS:
    -n, --nested        "$"Assume any value beginning with \"declare -[aA]\" are nested data to be translate to JSON format""
    -s, --shift NUMBER  "$"Number of tabs shifting of each line"" - "$"Default: ""$shft

"$"Warnings: --nested option may introduce security issues (because it uses eval on unsanitized inputs).""

"$"Reminder: bash arrays are not exportable to sub shells (cf. https://www.mail-archive.com/bug-bash@gnu.org/msg01774.html).""
        "$"Then you should better use directly the function, eg:""
        source bl-json ; bl_json_from_var ARRAYNAME"

	local nested=false shft=0
	for ((;$#;)) ; do
		case "$1" in
			-n|--nest*) nested=true ;;
			-s|--shift) shift ; shft=$(($1)) ;;
			-h|--help) printf "%s\n%s\n" "$usage" "$helpmsg" ; return ;;
			-V|--version) echo "$FUNCNAME $BL_JSON_VERSION" ; return ;;
			--) shift ; break ;;
			-*) printf "$FUNCNAME: Error: "$"Unrecognized option"" '$1'.\n\n"$"Try '%s --help' for more information"".\n" "$name" >&2 ; return 2 ;;
			*) break ;;
		esac
		shift
	done

	local varname lb i notstarted0=true notstarted

	! ((shft)) || lb=$(eval printf '\\t%.0s' {1..$shft})
	printf "$lb{\n"
	while (($#)) ; do
		local -n ce_nom_est_interdit=$1 || { shift ; continue ;}
		varname=$1
		shift
		$notstarted0 || printf ",\n"
		notstarted0=false
		notstarted=true
		echo -n "$lb	\"$(bl_json_from_str -- "$varname")\":"
		if  [[ ${ce_nom_est_interdit@a} =~ a ]] ; then
			local ai=("${!ce_nom_est_interdit[@]}")
			printf "[\n"
			for ((i=0;i<=${ai[-1]};i++)) ; do
				$notstarted || printf ",\n"
				notstarted=false
				if $nested && [[ "${ce_nom_est_interdit[$i]}" =~ ^declare\ -([Aa][a-zA-Z]*)\ ([a-zA-Z_][a-zA-Z0-9_]*)=\(.*\)$ ]] ; then
					eval "${ce_nom_est_interdit[$i]/#declare/local}" || true
					echo -n "$(bl_json_from_var --nested --shift $((shft+2)) "${BASH_REMATCH[2]}")"
				else
					echo -n "$lb		\"$(bl_json_from_str -- "${ce_nom_est_interdit[$i]}")\""
				fi
			done
			printf "\n$lb		]"
		elif [[ ${ce_nom_est_interdit@a} =~ A ]] ; then
			printf "{\n"
			for i in "${!ce_nom_est_interdit[@]}"; do
				$notstarted || printf ",\n"
				notstarted=false
				if $nested && [[ "${ce_nom_est_interdit[$i]}" =~ ^declare\ -([Aa][a-zA-Z]*)\ ([a-zA-Z_][a-zA-Z0-9_]*)=\(.*\)$ ]] ; then
					eval "${ce_nom_est_interdit[$i]/#declare/local}" || true
					echo -n "$lb		\"$(bl_json_from_str -- "$i")\":"$'\n'"$(bl_json_from_var --nested --shift $((shft+2)) "${BASH_REMATCH[2]}")"
				else
					echo -n "$lb		\"$(bl_json_from_str -- "$i")\": \"$(bl_json_from_str -- "${ce_nom_est_interdit[$i]}")\""
				fi
			done
			printf "\n$lb		}"
		else
			if $nested && [[ "${ce_nom_est_interdit}" =~ ^declare\ -([Aa][a-zA-Z]*)\ ([a-zA-Z_][a-zA-Z0-9_]*)=\(.*\)$ ]] ; then
					eval "${ce_nom_est_interdit/#declare/local}" || true
				echo -n "$(bl_json_from_var --nested --shift $((shft+2)) "${BASH_REMATCH[2]}")"
			else
				echo -n "\"$(bl_json_from_str -- "${ce_nom_est_interdit}")\""
			fi
		fi
	done
	printf "\n$lb}\n"
}

bl_json_type() {
	local name
	((BL_JSON_isprogram)) && name="$BL_JSON_NAME ${FUNCNAME:8}" || name="$FUNCNAME"
	local usage="Usage: $name [JSON_TEXT]"
	local helpmsg="
"$"Expect JSON type regarding the begining of JSONTEXT.""

"$"Like jq 'type' filter whithout needing a full valid JSON input,""
"$"as it only check the begining to determine expected json type between:""
"$"number, string, boolean, array, object, null.""

"$"If there is no JSON_TEXT in command line, read it from stdin.""
"$"If no type can be determined, output nothing and return non-zero value.""
"

	for ((;$#;)) ; do
		case "$1" in
			-h|--help) printf "%s\n%s\n" "$usage" "$helpmsg" ; return ;;
			-V|--version) echo "$FUNCNAME $BL_JSON_VERSION" ; return ;;
			--) shift ; break ;;
			-*) printf "$FUNCNAME: Error: "$"Unrecognized option"" '$1'.\n\n"$"Try '%s --help' for more information"".\n" "$name" >&2 ; return 2 ;;
			*) break ;;
		esac
		shift
	done

	local first

	(($#)) && first="$1" || read first

	# remove leading whitespace
	first="$(sed 's,^[[:space:]]*,,' <<<"$first")"

	case "$first" in
		null|null,) echo "null" ; return ;;
		{*) echo "object" ; return ;;
		\[*) echo "array" ; return ;;
		true|false|true,|false,) echo "boolean" ; return ;;
		\"*) echo "string" ; return ;;
	esac

	if [[ "$first" =~ ^-?[0-9]*\.?[0-9]+,?$ ]] ; then
		echo "number"
		return
	else
		echo "$FUNCNAME: Warning: Can't expect json type of ${first::5}..." >&2
		return 1
	fi
}

bl_json_to_var() {
	local name
	((BL_JSON_isprogram)) && name="$BL_JSON_NAME ${FUNCNAME:8}" || name="$FUNCNAME"
	local usage="Usage: $name [OPTIONS] [JSON_TEXT]"
	local helpmsg="
"$"Output string to be put into bash variable or array.""
"$"If JSON_TEXT is not given, read JSON from sdtdin.""

OPTIONS:
    -d, --declare VARNAME  output \"declare -[aA-] VARNAME=...\" statement to be use with 'eval ...'.
    -r, --recurse          "$"also parse JSON arrays or objects nested in JSON_TEXT.""

"$"Examples:""
* "$"if JSON_TEXT refer to JSON boolean, string or number:"" bash_var=\$($name [JSON_TEXT])
* "$"if JSON_TEXT refer to JSON array or object:"" bash_array=( \$($name [JSON_TEXT]) )""
* "$"if you just have a json file to be put in a bash variable:"" eval \$($name --declare bash_variable < file.json)
"

	local recurse=false varname
	for ((;$#;)) ; do
		case "$1" in
			-d|--declare) shift
				[[ "${varname:=$1}" =~ ^[a-zA-Z_][0-9a-zA-Z_]*$ ]] || { echo "$FUNCNAME: Error: given VARNAME is not a valid posix name ${varname::5}..." >&2  ; return 2 ; }
				;;
			-r|--recurs*) recurse=true ; echo "$FUNCNAME: Error: option $1 is not supported yet." >&2 ; return 3 ;;
			-h|--help) printf "%s\n%s\n" "$usage" "$helpmsg" ; return ;;
			-V|--version) echo "$FUNCNAME $BL_JSON_VERSION" ; return ;;
			--) shift ; break ;;
			-*) printf "$FUNCNAME: Error: "$"Unrecognized option"" '$1'.\n\n"$"Try '%s --help' for more information"".\n" "$name" >&2 ; return 2 ;;
			*) break ;;
		esac
		shift
	done

	local lines jsontype

	# validate and format json
	if (($#)) then
		mapfile -t lines < <(jq <<<"$@" )
	else
		mapfile -t lines < <(jq)
	fi

	jsontype="$(bl_json_type "${lines[0]}")"
	case "$jsontype" in
		null)
			if [[ "$varname" ]] ; then
				printf "declare -- $varname=''\n"
			else
				printf "%s: Info: JSON 'null' -> BASH ''.\n" "$FUNCNAME" >&2
			fi
			return
			;;
		boolean|string|number)
			[[ -z "$varname" ]] || printf "declare -- $varname="
			echo "${lines[0]%,}" # remove eventual trailing comma
			return
			;;

		array) [[ -z "$varname" ]] || echo "declare -a -- $varname=(" ;;
		object) [[ -z "$varname" ]] || echo "declare -A -- $varname=(" ;;
		*) echo "$FUNCNAME: Error:" $"Invalid JSON" "(${lines[0]:: 8}...)." >&2
		   return 2 ;;
	esac

	local keys key jkey value

	local sed4eval='s,\\,\\\\,g ; s,\$,\\$,g ; '
	local sed4jvalue
	[[ -z "$varname" ]] || sed4jvalue="$sed4eval"

	eval keys=($(echo "${lines[@]}" | jq '. | keys' | sed 's,^.,, ; s/,$// ; '"$sed4eval" ))
	for key in "${keys[@]}" ; do
		[[ "$jsontype" == array ]] && jkey=$key || jkey="\"$key\""
		value=$(jq ".[$jkey]" <<<"${lines[@]}")
		printf "[${key@Q}]="
		case "$(bl_json_type "${value}")" in
			null)
				echo '""' ;;
			boolean|string|number)
				echo "$value" | sed "$sed4jvalue" ;;
			array|object)
				# escape all double quotes, then double quote all.
				echo "$value" | sed "$sed4jvalue"'s,",\\",g ; 1s,^,", ; $s,$,", ; ' ;;
			*)
				echo
				echo "$FUNCNAME: Error:" $"Invalid JSON" "(${lines[0]:: 8}...)." >&2
				return 2 ;;
		esac
	done

	[[ "$varname" ]] && echo ")"

	return 0
}

### Init ###

if ((BL_JSON_isprogram)) ; then
	BL_JSON_usage="Usage: $BASH_SOURCE [MAIN_OPTIONS]... ACTION [OPTIONS]... [ARGUMENTS]..."
	BL_JSON_shelpmsg="
  -h, --help              "$"Show help and exit.""
  -V, --version           "$"Show version and exit.""

ACTIONS:
$(for f in "${BL_JSON_FUNCTIONS[@]}" ; do printf "   %-19s%s\n" "${f:8}" "$($f --help | sed -n '/^$/{n;p;q}')" ; done)

All actions support a --help option, eg:
$ $BASH_SOURCE ${BL_JSON_FUNCTIONS:8} --help

$BL_JSON_NAME is also bash library, see:
$ source $BASH_SOURCE --help"
else
	BL_JSON_usage="Usage: source $BASH_SOURCE [MAIN_OPTIONS]..."
	BL_JSON_shelpmsg="
      --bash-completion    set completion for $BASH_SOURCE program and return (without loading anything else)

Functions:
$(for f in "${BL_JSON_FUNCTIONS[@]}" ; do printf "   %-19s%s\n" "$f" "$($f --help | sed -n '/^$/{n;p;q}')" ; done)

"$"Reminder: when used as a library, all functions calls share the same environment variables, i.e. the same global options."""
fi

# Parse Options
_BL_JSON_RETVAL=0
_bl_json_parseoptions "$@" || _BL_JSON_RETVAL=$?

# Do nothing else if sourced
[[ "$BASH_SOURCE" == "$0" ]] || return $_BL_JSON_RETVAL

[[ "$_BL_JSON_RETVAL" != "1"  ]] || exit 0
[[ "$_BL_JSON_RETVAL" < 2 ]] || exit $_BL_JSON_RETVAL

### Run ###
set -e
shift $BL_JSON_NOPTIONS
if function=$(grep -o "\<bl_json_$1\>" <<< "${BL_JSON_FUNCTIONS[@]}") ; then
	shift
	$function "$@"
	exit $?
else
	printf "$BL_JSON_NAME: Error: "$"Unrecognized action"" '$1'.\n\n"$"Try '%s --help' for more information"".\n" "$BASH_SOURCE" >&2
	exit 2
fi

