#!/usr/bin/env bash
# Desc: Create and sign a checksum
# Usage: bksum file.txt
# Input: stdin: file list (newline delimited)
#        arg(s): file paths (IFS delimited)
# Output: file containing sha256 hashes and file paths
# Depends: bash v5.1.16, date (GNU Coreutils 8.32), gpg v2.2.27, ots v0.7.0
# Version: 0.1.1

declare -Ag appRollCall # Associative array for storing app status
declare -Ag fileRollCall # Associative array for storing file status
declare -Ag dirRollCall # Associative array for storing dir status
declare -ag arrPosArgs; # positional arguments
declare -ag arrStdin; # standard input lines
declare -ag arrInFiles; # input files

yell() { echo "$0: $*" >&2; } # print script path and all args to stderr
die() { yell "$*"; exit 111; } # same as yell() but non-zero exit status
try() { "$@" || die "cannot $*"; } # runs args as command, reports args if command fails
vbm() {
    # Description: Prints verbose message ("vbm") to stderr if opVerbose is set to "true".
    # Usage: vbm "DEBUG :verbose message here"
    # Version 0.2.0
    # Input: arg1: string
    #        vars: opVerbose
    # Output: stderr
    # Depends: bash 5.1.16, GNU-coreutils 8.30 (echo, date)

    if [ "$opVerbose" = "true" ]; then
	functionTime="$(date --iso-8601=ns)"; # Save current time in nano seconds.
	echo "[$functionTime]:$0:""$*" 1>&2;  # Display argument text.
    fi

    # End function
    return 0; # Function finished.
} # Displays message if opVerbose true
showUsage() {
    # Desc: Display script usage information
    # Usage: showUsage
    # Version 0.0.2
    # Input: none
    # Output: stdout
    # Depends: GNU-coreutils 8.30 (cat)
    cat <<'EOF'
    USAGE:
        bksum [ options ] [FILE...]

    DESCRIPTION:
        Creates sha256 checksum of files.

    OPTIONS:
        -h, --help
                Display help information.
        --version
                Display script version.
        -v, --verbose
                Display debugging info.
        -o, --output-file
                Define output file path. By default, the file
                name includes the full ISO-8601 timestamp
                without separators, e.g.:
                    20220920T2117+0000..SHA256SUM
        -s, --sign
                Sign with GnuPG the checksum file.
        -t, --timestamp
                Timestamp with OpenTimestamps the checksum file
                (and GnuPG signature if -s/--sign specified).
        -T, --timestamp-wait
                Same as '-t/--timestamp' except that
                OpenTimestamps will run as a background process
                until a calendar server returns a completed
                timestamp.
        --
                Indicate end of options.

    EXAMPLE:
      bksum file.txt
      bksum file1.txt file2.txt
      bksum -v -- file1.txt file2.txt
      bksum -v -t -- file1.txt file2.txt
      find . -type f | bksum
      find . -type f | bksum -v -s -t -- file.txt

    NOTE:
        If GNU Coreutils 8.32 `sha256sum` used, checksum file
        can be verified using:
        sha256sum --check 20220920T2117+0000..SHA256SUM
EOF
} # Display information on how to use this script.
showVersion() {
    # Desc: Displays script version and license information.
    # Usage: showVersion
    # Version: 0.0.2
    # Input: scriptVersion   var containing version string
    # Output: stdout
    # Depends: vbm(), yell, GNU-coreutils 8.30

    # Initialize function
    vbm "DEBUG:showVersion function called."

    cat <<'EOF'
bksum 0.0.1
Copyright (C) 2022 Steven Baltakatei Sandoval
License GPLv3: GNU GPL version 3
This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

EOF
    
    # End function
    vbm "DEBUG:showVersion function ended."
    return 0; # Function finished.
} # Display script version.
checkapp() {
    # Desc: If arg is a command, save result in assoc array 'appRollCall'
    # Usage: checkapp arg1 arg2 arg3 ...
    # Version: 0.1.1
    # Input: global assoc. array 'appRollCall'
    # Output: adds/updates key(value) to global assoc array 'appRollCall'
    # Depends: bash 5.0.3
    local returnState    

    #===Process Args===
    for arg in "$@"; do
	if command -v "$arg" 1>/dev/null 2>&1; then # Check if arg is a valid command
	    appRollCall[$arg]="true";
	    if ! [ "$returnState" = "false" ]; then returnState="true"; fi;
	else
	    appRollCall[$arg]="false"; returnState="false";
	fi;
    done;

    #===Determine function return code===
    if [ "$returnState" = "true" ]; then
	return 0;
    else
	return 1;
    fi;
} # Check that app exists
checkfile() {
    # Desc: If arg is a file path, save result in assoc array 'fileRollCall'
    # Usage: checkfile arg1 arg2 arg3 ...
    # Version: 0.1.2
    # Input: global assoc. array 'fileRollCall'
    # Output: adds/updates key(value) to global assoc array 'fileRollCall';
    # Output: returns 0 if app found, 1 otherwise
    # Depends: bash 5.0.3
    local returnState

    #===Process Args===
    for arg in "$@"; do
	if [ -f "$arg" ]; then
	    fileRollCall["$arg"]="true";
	    if ! [ "$returnState" = "false" ]; then returnState="true"; fi;
        elif [ -z "$arg" ]; then
            fileRollCall["(no name)"]="false"; returnState="false";
	else
	    fileRollCall["$arg"]="false"; returnState="false";
	fi;
    done;
    
    #===Determine function return code===
    if [ "$returnState" = "true" ]; then
	return 0;
    else
	return 1;
    fi;
} # Check that file exists
checkdir() {
    # Desc: If arg is a dir path, save result in assoc array 'dirRollCall'
    # Usage: checkdir arg1 arg2 arg3 ...
    # Version 0.1.2
    # Input: global assoc. array 'dirRollCall'
    # Output: adds/updates key(value) to global assoc array 'dirRollCall';
    # Output: returns 0 if all args are dirs; 1 otherwise
    # Depends: Bash 5.0.3
    local returnState

    #===Process Args===
    for arg in "$@"; do
	if [ -z "$arg" ]; then
	    dirRollCall["(Unspecified Dirname(s))"]="false"; returnState="false";
	elif [ -d "$arg" ]; then
	    dirRollCall["$arg"]="true";
	    if ! [ "$returnState" = "false" ]; then returnState="true"; fi
	else
	    dirRollCall["$arg"]="false"; returnState="false";
	fi
    done
    
    #===Determine function return code===
    if [ "$returnState" = "true" ]; then
	return 0;
    else
	return 1;
    fi
} # Check that dir exists
displayMissing() {
    # Desc: Displays missing apps, files, and dirs
    # Usage: displayMissing
    # Version 1.0.0
    # Input: associative arrays: appRollCall, fileRollCall, dirRollCall
    # Output: stderr: messages indicating missing apps, file, or dirs
    # Output: returns exit code 0 if nothing missing; 1 otherwise
    # Depends: bash 5, checkAppFileDir()
    local missingApps value appMissing missingFiles fileMissing
    local missingDirs dirMissing

    #==BEGIN Display errors==
    #===BEGIN Display Missing Apps===
    missingApps="Missing apps  :";
    #for key in "${!appRollCall[@]}"; do echo "DEBUG:$key => ${appRollCall[$key]}"; done
    for key in "${!appRollCall[@]}"; do
	value="${appRollCall[$key]}";
	if [ "$value" = "false" ]; then
	    #echo "DEBUG:Missing apps: $key => $value";
	    missingApps="$missingApps""$key ";
	    appMissing="true";
	fi;
    done;
    if [ "$appMissing" = "true" ]; then  # Only indicate if an app is missing.
	echo "$missingApps" 1>&2;
    fi;
    unset value;
    #===END Display Missing Apps===

    #===BEGIN Display Missing Files===
    missingFiles="Missing files:";
    #for key in "${!fileRollCall[@]}"; do echo "DEBUG:$key => ${fileRollCall[$key]}"; done
    for key in "${!fileRollCall[@]}"; do
	value="${fileRollCall[$key]}";
	if [ "$value" = "false" ]; then
	    #echo "DEBUG:Missing files: $key => $value";
	    missingFiles="$missingFiles""$key ";
	    fileMissing="true";
	fi;
    done;
    if [ "$fileMissing" = "true" ]; then  # Only indicate if an app is missing.
	echo "$missingFiles" 1>&2;
    fi;
    unset value;
    #===END Display Missing Files===

    #===BEGIN Display Missing Directories===
    missingDirs="Missing dirs:";
    #for key in "${!dirRollCall[@]}"; do echo "DEBUG:$key => ${dirRollCall[$key]}"; done
    for key in "${!dirRollCall[@]}"; do
	value="${dirRollCall[$key]}";
	if [ "$value" = "false" ]; then
	    #echo "DEBUG:Missing dirs: $key => $value";
	    missingDirs="$missingDirs""$key ";
	    dirMissing="true";
	fi;
    done;
    if [ "$dirMissing" = "true" ]; then  # Only indicate if an dir is missing.
	echo "$missingDirs" 1>&2;
    fi;
    unset value;
    #===END Display Missing Directories===

    #==END Display errors==
    #==BEGIN Determine function return code===
    if [ "$appMissing" == "true" ] || [ "$fileMissing" == "true" ] || [ "$dirMissing" == "true" ]; then
	return 1;
    else
	return 0;
    fi
    #==END Determine function return code===
} # Display missing apps, files, dirs
processArgs() {
    # Desc: Processes arguments provided to script
    # Usage: processArgs "$@"
    # Version: 1.0.0 (modified)
    # Input: "$@"          (list of arguments provided to the function)
    # Output: Sets following variables used by other functions:
    #   opVerbose            Indicates verbose mode enable status.  (ex: "true", "false")
    #   pathDirOut1          Path to output directory.
    #   pathFileOut1         Path to output file.
    #   opFileOut1_overwrite Indicates whether file pathFileOut1 should be overwritten (ex: "true", "false").
    #   opTs                 Indicates timestamp mode
    #   opTsWait             Indicates timestamp mode with wait option
    #   arrPosArgs         Array of remaining positional argments
    # Depends:
    #   yell()           Displays messages to stderr.
    #   vbm()            Displays messsages to stderr if opVerbose set to "true".
    #   showUsage()      Displays usage information about parent script.
    #   showVersion()    Displays version about parent script.
    #   arrPosArgs     Global array for storing non-option positional arguments (i.e. arguments following the `--` option).
    # External dependencies: bash (5.1.16), echo
    # Ref./Attrib.:
    #  [1]: Marco Aurelio (2014-05-08). "echo that outputs to stderr". https://stackoverflow.com/a/23550347
    #  [2]: "Handling positional parameters" (2018-05-12). https://wiki.bash-hackers.org/scripting/posparams

    # Initialize function
    vbm "DEBUG:processArgs function called."

    # Perform work
    while [ ! $# -eq 0 ]; do   # While number of arguments ($#) is not (!) equal to (-eq) zero (0).
	#yell "DEBUG:Starting processArgs while loop." # Debug stderr message. See [1].
        #yell "DEBUG:Provided arguments are:""$*"      # Debug stderr message. See [1].
	case "$1" in
	    -h | --help) showUsage; exit 1;; # Display usage.
	    --version) showVersion; exit 1;; # Show version
	    -v | --verbose) opVerbose="true"; vbm "DEBUG:Verbose mode enabled.";; # Enable verbose mode. See [1].
	    -o | --output-file) # Define output file path
		if [ -f "$2" ]; then # If $2 is file that exists, prompt user to continue to overwrite, set pathFileOut1 to $2, pop $2.
		    yell "Specified output file $2 already exists. Overwrite? (y/n):"
		    read -r m; case $m in
				y | Y | yes) opFileOut1_overwrite="true";;
				n | N | no) opFileOut1_overwrite="false";;
				*) yell "Invalid selection. Exiting."; exit 1;;
			    esac
		    if [ "$opFileOut1_overwrite" == "true" ]; then
			pathFileOut1="$2";
			shift;
			vbm "DEBUG:Output file pathFileOut1 set to:""$2";
		    else
			yell "ERORR:Exiting in order to not overwrite output file:""$pathFileOut1";
			exit 1;
		    fi
		else
		    pathFileOut1="$2";
		    shift;
		    vbm "DEBUG:Output file pathFileOut1 set to:""$2";
		fi ;;
            -s | --sign) # sign with gpg
                opSign="true"; vbm "DEBUG:Signing mode enabled.";;
            -t | --timestamp) # timestamp with ots
                opTs="true"; vbm "DEBUG:Timestamp mode enabled.";;
            -T | --timestamp-wait) # timestamp with ots with wait option
                opTs="true"; vbm "DEBUG:Timestamp mode enabled.";
                opTsWait="true"; vbm "DEBUG:Timestamp wait mode enabled.";;
            --) # End of all options. See [2].
                shift;
                for arg in "$@"; do
                    vbm "DEBUG:adding to arrPosArgs:$arg";
                    arrPosArgs+=("$arg");
                done;
                break;;
            -*) showUsage; yell "ERROR: Unrecognized option."; exit 1;; # Display usage
            *)
                for arg in "$@"; do
                    vbm "DEBUG:adding to arrPosArgs:$arg";
                    arrPosArgs+=("$arg");
                done;
                break;;
	esac;
	shift;
    done;
   
    # End function
    vbm "DEBUG:processArgs function ended."
    return  0; # Function finished.
} # Evaluate script options from positional arguments (ex: $1, $2, $3, etc.).
processStdin() {
    # Desc: Save stdin lines to array
    # Input:  stdin         standard input
    #         arrStdin    array for storing stdin lines
    # Output: arrStdin    array for storing stdin lines
    # Ref/Attrib: [1] https://unix.stackexchange.com/a/484643 Check if no command line arguments and STDIN is empty
    
    if [[ -t 0 ]]; then
        return 1; # error if file descriptor 0 (stdin) open
    else
        while read -r line; do
            arrStdin+=("$line"); done;
        return 0;
    fi; 
}; # Save stdin to array
checkInput() {
    # Desc: Check that files (specified by positional arguments and
    #   standard input lines) exist and are in the same directory.
    # Input: arrPosArgs[@]    positional arguments
    #        arrStdin[@]      standard input lines
    # Output: return code 0     success
    #         return code 1     failure
    #         arrInFiles        list of verified files
    # Depends: displayMissing(), checkfile();
    local flagMissing flagDirErr workDir;

    # Check that positional arguments are files
    for elem in "${arrPosArgs[@]}"; do
        if checkfile "$elem"; then
            arrInFiles+=("$elem");
        else
            flagMissing="true";
        fi;
    done;

    # Check that stdin lines are files
    for elem in "${arrStdin[@]}"; do
        if checkfile "$elem"; then
            arrInFiles+=("$elem");
        else
            flagMissing="true";
        fi;
    done;

    # Check that all files are in the same directory
    if [[ "$flagMissing" != "true" ]]; then
        workDir="$( dirname "$( readlink -f "${arrInFiles[0]}" )" )";
        for elem in "${arrInFiles[@]}"; do
            elem="$(readlink -f "$elem")"; # full path            
            if [[ "$(dirname "$elem")" != "$workDir" ]]; then
                flagDirErr="true";
            fi;
        done;
    fi;
    
    # Return non-zero if displayMissing() reports files missing.
    if [[ "$flagMissing" == "true" ]]; then
        displayMissing; return 1; fi;
    if [[ "$flagDirErr" == "true" ]]; then
        yell "ERROR:All files not in same directory.";
        return 1; fi;
    return 0;
}; # Check positional arguments
checkDepends() {
    # Desc: Check if expected commands available

    checkapp date sha256sum;
    if [[ $opSign == "true" ]]; then checkapp gpg; fi;
    if [[ $opTs == "true" ]]; then checkapp ots; fi;
    
    # Return failure is displayMissing() reports apps missing.
    if ! displayMissing; then return 1; else return 0; fi;
}; # Check dependencies
count_jobs() {
    # Desc: Count and return total number of jobs
    # Usage: count_jobs
    # Input: None.
    # Output: stdout   integer number of jobs
    # Depends: Bash 5.1.16
    # Example: while [[$(count_jobs) -gt 0]]; do echo "Working..."; sleep 1; done;
    # Version: 0.0.1
    
    local job_count;
    job_count="$(jobs -r | wc -l | tr -d ' ' )";
    #yell "DEBUG:job_count:$job_count";
    if [[ -z $job_count ]]; then job_count="0"; fi;
    echo "$job_count";
}; # Return number of background jobs
main() {
    # Input: pathFileOut1   option-specified output file path
    #        appRollCall    assoc-array for checkapp(), displayMissing()
    #        fileRollCall   assoc-array for checkfile(), displayMissing()
    #        dirRollCall    assoc-array for checkdir(), displayMissing()
    #        arrPosArgs     array for processArgs()
    #        arrStdin       array for processStdin()
    #        arrInFiles     array for checkInput()
    #        (args)         for processArgs()
    #        (stdin)        for processStdin()
    # Output: file    written to $pathSumOut
    #         file    written to $pathSigOut
    #       
    local fileSumOut dirOut pathSumOut pathSigOut;
    
    # Check dependencies and input
    if ! checkDepends; then die "FATAL:Missing apps."; fi;
    processArgs "$@";
    processStdin;
    vbm "DEBUG:$(declare -p arrPosArgs)";
    vbm "DEBUG:$(declare -p arrStdin)";
    if ! [[ "${#arrPosArgs[@]}" -ge 1 || "${#arrStdin[@]}" -ge 1 ]]; then
        yell "ERROR:No positional arguments or stdin lines.";
        showUsage; exit 1; fi;
    if ! checkInput; then die "FATAL:Invalid file list."; fi;
    vbm "DEBUG:$(declare -p arrInFiles)";

    # Do work
    if [[ -n "$pathFileOut1" ]]; then
        pathSumOut="$pathFileOut1";
    else
        dirOut="$( dirname "${arrInFiles[0]}" )";
        fileSumOut="$(date +%Y%m%dT%H%M%S%z)..SHA256SUMS";
        pathSumOut="$dirOut"/"$fileSumOut";
    fi;
    pathSigOut="$pathSumOut".asc;
    for file in "${arrInFiles[@]}"; do
        sha256sum "$file" >> "$pathSumOut";
    done;

    # Do optional work
    ## Sign checksum file.
    if [[ $opSign == "true" ]]; then
        yell "STATUS:Signing checksum file...";
        try gpg --detach-sign --armor --output "$pathSigOut" "$pathSumOut" && yell "STATUS:Checksum created.";
    fi;
    ## Timestamp checksum file.
    if [[ $opTs == "true" ]]; then
        yell "STATUS:Timestamping checksum file...";
        if [[ $opTsWait != "true" ]]; then
            try ots s "$pathSumOut" &
        elif [[ $opTsWait == "true" ]]; then
            yell "NOTICE:Waiting for calendar server response in background. (This may take 8 to 24 hours)...";
            yell "ADVICE:Do not close or suspend this terminal.";
            try ots --wait s "$pathSumOut" 1>/dev/random 2>&1 &
        fi;    
    fi;
    ## Timestamp checksum signature file.
    if [[ $opTs == "true" && $opSign == "true" ]]; then
        yell "STATUS:Timestamping signature file...";
        if [[ $opTsWait != "true" ]]; then
            try ots s "$pathSigOut" && yell "STATUS:Timestamp of checksum signature file created.";
        elif [[ $opTsWait == "true" ]]; then
            yell "NOTICE:Waiting for calendar server response in background. (This may take 8 to 24 hours)...";
            yell "ADVICE:Do not close or suspend this terminal.";
            try ots --wait s "$pathSigOut" 1>/dev/random 2>&1 &
        fi;
    fi;

    ## Wait until background jobs (if any) completed
    for (( n = 1; "$(count_jobs)" > 0; n++ )); do
        if ! [[ $((n % 60)) -eq 0 ]]; then
            printf ".";
        else
            printf ".%05ds passed.\n" "$SECONDS";
        fi;
        sleep 60;
    done;
    
    yell "Done.";
    return 0;
}; # main program

main "$@";

# Author: Steven Baltakatei Sandoval
# License: GPLv3+